Перейти к содержимому
Поиск
Разработка

TanStack Form во Vue: формы, ошибки и API — мастерская и 8 интерфейсов

Восемь форм на настоящем TanStack Form: профиль, реквизиты, счёт, даты, async-проверка, загрузка, ответы API и шаги. Мастерская, PHP endpoint и исходники Windows/Linux.

Александр Некрасов
TanStack Form во Vue: формы, ошибки и API — мастерская и 8 интерфейсов — обложка

TanStack Form во Vue на практике: восемь рабочих интерфейсов, ошибки и асинхронность, массивы, шаги, PHP API, исходники и запуск Windows/Linux.

TanStack Form: форма, которая объясняет, что происходит

Пользователь открыл карточку клиента, изменил почту и нажал «Сохранить». Хорошая форма покажет, какие поля ещё требуют внимания, дождётся ответа, объяснит конфликт и сохранит введённый текст при временном сбое. Именно эти небольшие реакции превращают набор полей в удобный рабочий инструмент. TanStack Form помогает управлять значениями, проверками и состоянием отправки, а внешний вид остаётся под вашим контролем.

В статье мы соберём восемь интерфейсов на Vue: профиль, заявку организации, счёт с позициями, бронирование, проверку имени пользователя, редактирование загруженной карточки, обработку ответов API и заказ по шагам. У каждого есть понятное действие и результат. Можно менять данные прямо на странице, увидеть payload, получить ошибку и исправить её, затем скачать проект и перенести подход в свою CRM или сервис.

Мастерская работает на настоящем @tanstack/vue-form. Её оформление построено на HTML5 и Bootstrap 5, зависимости включены в локальную сборку. По умолчанию ответы API создаёт ограниченная фикстура в браузере. В архиве есть отдельный PHP-обработчик: он принимает реальный HTTP-запрос, заново проверяет значения и возвращает JSON. Такое разделение позволяет изучать интерфейс на странице и затем пройти полный путь до сервера.

Где применять: CRM, заявки, настройки и коммерческие документы

В CRM форма нужна почти на каждом экране: карточка клиента, контакт, договор, задача, фильтр отчёта. Даже когда полей немного, возникают общие вопросы: когда показывать ошибку, как сбросить изменения, что делать с поздним ответом API. Один и тот же компонент поля помогает держать эти решения последовательными. Оператору не приходится заново учиться взаимодействию с каждой страницей.

В сервисе для заказчиков часто встречаются условные разделы. Частному лицу не нужны реквизиты организации, а для юридического лица эти значения обязательны. В счёте число строк меняется, цена участвует в расчёте суммы, удаление строки сдвигает индексы. В настройках интеграции часть проверок требует обращения к API. Такие случаи удобнее разбирать через явную модель формы, чем через десятки независимых переменных.

Библиотека особенно полезна, когда простая форма уже выросла, но менять всё приложение ради неё не хочется. Можно начать с одного экрана и собственных Bootstrap-компонентов. При этом для одной поисковой строки без проверок отдельный слой состояния может быть избыточным: сначала оцените реальные сценарии, количество переходов и требования к обратной связи.

Что делает библиотека и что остаётся приложению

TanStack Form относится к библиотекам без готовой визуальной оболочки: она управляет формой, но не рисует за вас дизайн-систему. Мы сами выбираем подпись, отступы, цвет ошибки, расположение кнопок и поведение фокуса. Это удобно для существующего сайта: форма может выглядеть так же, как остальные разделы, а правила работы со значениями остаются общими.

В нашем проекте библиотека хранит поля, отмечает изменение и взаимодействие, запускает синхронные и асинхронные проверки, вызывает обработчик отправки. Приложение решает, какой JSON отправлять, как читать ответ 422, какую версию записи сохранить, кому разрешено действие. Авторизация, база данных, письма и платежи не появляются автоматически вместе с установкой пакета.

Практический ориентир такой: браузерная форма помогает человеку выполнить действие правильно, сервер определяет, допустимо ли действие на самом деле. Нельзя доверять сумме, роли или идентификатору записи только потому, что они пришли из красивого интерфейса. В PHP-примере мы отдельно проверяем типы и собираем результат из разрешённых полей.

Задача Наше решение
Состояние и проверки формы TanStack Form
Разметка и реактивный интерфейс Vue 3 и Bootstrap 5
Ответы на странице Явно обозначенная локальная фикстура
Серверная проверка в архиве PHP endpoint с ограниченным контрактом
Права, БД, транзакции Прикладной backend вашего сервиса

Версии, установка и воспроизводимая сборка

Примеры подготовлены для TanStack Form 1.33.5 и Vue 3.5.45. В проекте также закреплены Bootstrap 5.3.9, Vite 8.3.8 и Vue-плагин Vite 6.0.11; версии сверены 12 ноября 2026 года. Для выполнения всех проверок архива используйте Node.js 24.15 или новее в поддерживаемой ветке 24 LTS. Для серверного варианта нужен PHP 8.2+.

Файл package-lock.json важен не меньше, чем список зависимостей. Команда npm ci устанавливает зафиксированное дерево, а обычное обновление пакетов — отдельная задача с повторной проверкой поведения. В архиве уже есть готовая сборка public, но для изменения исходников и выполнения тестов зависимости нужно установить.

Обычный старт Vue описан в официальном руководстве. В нашем варианте значения передаются через useForm, а поля подключаются через привязанный компонент form.Field. Читаемые исходники доступны рядом с мастерской: можно посмотреть не только итоговый bundle, но и компоненты, правила и обработчики действий. Официальный Vue Quick Start

npm install --save-exact @tanstack/vue-form@1.33.5 vue@3.5.45
# Для скачанного проекта используйте существующий lock:
npm ci
npm run build
npm test

Мини-мастерская: восемь форм и свой результат

Выберите опыт в верхней части мастерской. Сначала выполните подсказку «Попробуйте», затем измените значения по-своему. Справа на широком экране находится текущий payload; на телефоне этот блок идёт под формой. Он показывает, какие данные будут переданы обработчику, и помогает увидеть разницу между пустой строкой, числом, объектом и массивом.

Кнопка «Проверить и отправить» запускает правила и демонстрационный ответ. Во время ожидания повторная отправка отключается. «Сбросить» возвращает исходные значения и отменяет незавершённые действия данного опыта. Экспорт JSON сохраняет именно текущий ввод: он полезен для исследования структуры, но не означает, что сервер уже принял документ.

На этой странице нет публичной базы клиентов. Имена, почта, строки счёта и ответы API учебные; ввод остаётся в браузере. Встроенная задержка нужна, чтобы успеть увидеть проверку и отправку. Для самостоятельного PHP-режима в архиве есть точная команда запуска и отдельный набор HTTP-проверок. Если рамка кажется тесной, откройте мастерскую отдельной страницей.

Значение, touched, dirty и отправка: читаем состояние

У поля есть текущее значение, но для хорошего интерфейса этого мало. Нужно понимать, взаимодействовал ли с ним человек и изменил ли исходные данные. В мастерской эти состояния выведены отдельными метками. Они помогают отличить пустую новую форму от формы, в которой пользователь уже попытался выполнить действие.

Состояние isDirty полезно для предложения сохранить изменения или предупреждения перед уходом. isTouched помогает выбрать момент для подсказки об ошибке. isSubmitting управляет ожиданием ответа. Это разные вопросы: форма может содержать изменения, быть корректной и при этом ещё ни разу не отправляться.

В Vue мы подписываемся на состояние через form.useSelector. В небольшом учебном интерфейсе удобно читать весь объект; в большом приложении выбирайте только нужные части для конкретного компонента. Не создавайте рядом вторую независимую копию каждого значения через v-model, иначе у одной формы появятся два источника данных.

const form = useForm({
  defaultValues: { name: '', email: '' },
  onSubmit: async ({ value }) => saveProfile(value),
})
const state = form.useSelector(s => s)
const values = computed(() => state.value.values)
// state.value.isDirty, isTouched, isSubmitting

Опыт 1. Профиль клиента: ошибки после взаимодействия

Откройте «Профиль клиента» и нажмите отправку с пустыми значениями. Поля объяснят, что нужно исправить, а фокус перейдёт к первой ошибке. Затем воспользуйтесь заполненным примером или введите свои данные. Во время отправки кнопка покажет ожидание, после ответа появится результат.

Для имени и почты мы используем проверку при потере фокуса. После первого взаимодействия проверяем и изменения: ошибка должна исчезать, когда человек исправляет поле, без дополнительного нажатия на кнопку. При отправке вся форма проверяется повторно, включая поля, которые пользователь ещё не открывал.

Обёртка InputField связывает label и input, передаёт значение в handleChange, сообщает о blur и выводит сообщения из метаданных. Это хорошее место для общих решений дизайн-системы. Если завтра изменится вид ошибки или появится счётчик символов, не придётся переписывать каждую форму. Механизмы событий описаны в документации по валидации.

<form.Field name="name"
  :validators="{ onBlur: ({ value }) =>
    value.trim() ? undefined : 'Укажите имя' }">
  <template #default="{ field }">
    <label for="client-name">Имя</label>
    <input id="client-name"
      :value="field.state.value"
      @input="field.handleChange($event.target.value)"
      @blur="field.handleBlur">
    <p>{{ field.state.meta.errors.join(' · ') }}</p>
  </template>
</form.Field>

Вложенные поля и понятный контракт данных

Телефон профиля хранится в contact.phone. Такая структура похожа на будущий JSON API и удобна, когда у контакта появляются дополнительные свойства: предпочитаемый канал, добавочный номер, комментарий. Точечный путь позволяет связать один input с конкретным значением внутри объекта.

Однако вложенность не должна усложнять интерфейс человеку. Пользователь видит подпись «Телефон», а не технический путь. Имя поля важно для кода и сопоставления серверной ошибки; текст сообщения объясняет действие обычными словами. В нашем PHP-контракте ошибка может прийти по пути contact.phone, и интерфейс знает, куда её показать.

Вложенный объект нужно включить в начальные значения заранее. Это делает структуру предсказуемой при первом рендере и сбросе. Для необязательного телефона в демо нет сложной маски или проверки страны: цель опыта — связать данные и жизненный цикл поля. Если реальная задача требует нормализации номера, добавьте отдельное правило на сервере.

defaultValues: {
  name: '',
  email: '',
  contact: { phone: '' },
}
// Имя связанного поля:
<form.Field name="contact.phone">...</form.Field>

Опыт 2. Организация: условные поля и скрытые значения

Во втором опыте переключатель меняет тип заявителя. Для организации появляются название и ИНН; для частного лица эти поля скрыты. Рядом есть отдельная настройка сохранения скрытых реквизитов. Попробуйте заполнить компанию, переключиться на частное лицо и вернуться: при сохранении значения восстановятся.

Здесь важно заранее определить продуктовую политику. Иногда скрытое поле нужно сохранить, потому что человек временно выбрал другой вариант. Иногда его следует очистить, чтобы не использовать устаревшую информацию. Универсального правильного поведения нет; ошибка возникает, когда поведение случайно зависит от того, размонтировался компонент или остался в DOM.

В нашей форме внутреннее значение и отправляемый payload разделены. Даже при сохранённых реквизитах JSON частного лица не содержит объекта company. Сервер тоже собирает разрешённый результат по типу заявителя. Проверка ИНН в мастерской намеренно ограничена цифрами и длиной: контрольная сумма и проверка контрагента относятся к отдельной бизнес-задаче.

function payload(kind, values) {
  const out = JSON.parse(JSON.stringify(values))
  if (kind === 'company' && out.type === 'person') delete out.company
  return out
}
// Сервер повторно выбирает разрешённые поля по type.

Опыт 3. Счёт: добавить, переставить и удалить позиции

Счёт показывает ситуацию, где форма становится коллекцией. У каждой позиции есть название, количество, цена и собственный ID. Добавьте третью строку, оставьте название пустым, затем попробуйте отправить документ. Ошибка должна относиться именно к этой позиции, а не к случайному месту после перестановки.

Массив подключён через form.Field с режимом array. Для действий используются методы поля pushValue, moveValue и removeValue. Внешняя карточка строки получает устойчивый ключ item.id. Путь внутреннего поля содержит текущий индекс, поэтому после перестановки привязка обновляется вместе с адресом значения.

Для учебного интерфейса мы ограничили число строк десятью и не разрешаем удалить последнюю. Пользователь всегда видит, где продолжить работу. В реальном документе ограничения зависят от задачи: длинный список может потребовать виртуализации, сворачивания подробностей или поиска по позициям. Работа массива описана в руководстве Arrays.

<form.Field name="items" mode="array">
  <template #default="{ field }">
    <article v-for="(item, i) in field.state.value" :key="item.id">
      <form.Field :name="'items[' + i + '].title'">...</form.Field>
      <button type="button" @click="field.removeValue(i)">Удалить</button>
    </article>
    <button type="button" @click="field.pushValue({
      id: crypto.randomUUID(), title: '', qty: 1, price: 0
    })">Добавить</button>
  </template>
</form.Field>

Числа и суммы: не превращаем пустое поле в ноль

HTML input возвращает строку. Для количества и цены это важно: Number('') даёт ноль, хотя человек мог просто стереть значение, собираясь ввести другое. Если немедленно подставить 0, интерфейс начнёт спорить с пользователем и потеряет различие между отсутствующим значением и введённым нулём.

В мастерской преобразование сохраняет пустую строку, а непустую переводит в число. Затем правило проверяет целочисленность и диапазон. Для количества ноль недопустим, для цены он разрешён: бесплатная позиция вполне возможна. Эти решения зависят от предметной области, поэтому их лучше записывать явно, а не прятать в универсальном преобразователе.

Итоговая сумма в демо вычисляется из текущих строк. Для настоящего счёта сервер должен пересчитать итог самостоятельно. В задачах с бухгалтерской точностью храните деньги в целых минимальных единицах или используйте десятичную арифметику; обычный JavaScript number не является готовой финансовой моделью. Клиентская сумма здесь служит быстрой обратной связью.

const numberValue = input => input === '' ? '' : Number(input)
const validQty = Number.isInteger(value) && value >= 1 && value <= 1000
const total = items.reduce((sum, row) =>
  sum + (Number(row.qty) || 0) * (Number(row.price) || 0), 0
)

Опыт 4. Бронирование: проверяем связанные поля

Дата окончания зависит от даты начала. Если проверять только изменяемое поле, можно получить противоречивый интерфейс: пользователь перенёс начало, а сообщение у окончания осталось старым. В четвёртом опыте измените обе даты и проследите, как пересчитывается ошибка.

Поле окончания слушает изменения начала через onChangeListenTo. Для этого отношения мы используем одну проверку onChange, чтобы сообщение из другого события не оставалось устаревшим. Такой же подход нужен для пар «пароль — повтор пароля», «минимальная — максимальная цена», «дата отгрузки — дата оплаты». Связи должны быть направленными и понятными, без циклов из взаимных обновлений.

В форме даты представлены строками ISO без времени. Это подходит для учебного бронирования по календарным дням. Если сервис работает со временем, часовыми поясами и переходами суток, контракт потребуется расширить. В PHP-примере сервер дополнительно проверяет существование календарной даты, а не только порядок строк. Подробнее о зависимости полей — Linked Fields.

validators: {
  onChangeListenTo: ['start'],
  onChange: ({ fieldApi }) => {
    const { start, end } = fieldApi.form.state.values
    return end < start ? 'Окончание раньше начала' : undefined
  },
}

Опыт 5. Имя пользователя: асинхронная проверка

Введите admin и дождитесь сообщения: в учебном наборе имя занято. Затем замените его на marina. Форма показывает, когда выполняется проверка, и позволяет увидеть успешное завершение без модального окна. Если быстро изменить значение несколько раз, итог должен относиться к последнему вводу.

Асинхронный валидатор использует задержку запуска 300 мс. Это уменьшает число обращений, пока человек печатает. Сама фикстура отвечает ещё через 450 мс, чтобы ожидание было заметно. На реальном сервере время непостоянно, поэтому интерфейс должен корректно переживать и быстрый, и медленный ответ.

В библиотечный валидатор передаётся AbortSignal. Наш запрос умеет его использовать; отменённый ответ не превращается в новую ошибку. Но проверка доступности имени в браузере остаётся предварительной. Между проверкой и сохранением имя может занять другой пользователь. Окончательное решение принимает сервер при записи, обычно вместе с уникальным ограничением в базе.

onChangeAsyncDebounceMs: 300,
onChangeAsync: async ({ value, signal }) => {
  const response = await checkUsername(value, { signal })
  return response.available ? undefined : 'Имя занято'
}

Отмена и устаревший ответ: почему одного debounce мало

Debounce откладывает начало запроса, но уже начатый запрос может завершиться позже нового. Представьте, что проверка admin заняла секунду, а проверка marina — сто миллисекунд. Без защиты старый ответ вернёт ошибку в поле, где давно написано другое имя. Пользователь увидит непонятное поведение и начнёт повторять действие.

В проекте есть два уровня защиты. Для запросов используется AbortController, а для смены опыта и сброса — номер поколения epoch. Обработчик запоминает поколение и перед изменением интерфейса сверяет его с текущим. Сброс увеличивает номер, поэтому незавершённое действие больше не имеет права обновлять экран.

Отмена браузерного запроса не гарантирует, что сервер ничего не сделал. Если операция уже записала заказ, закрытие соединения не откатит транзакцию автоматически. Поэтому для операций с побочным эффектом дополнительно нужны идентификатор запроса и серверная идемпотентность. В нашей мастерской данные не записываются, но разделение ответственности уже видно в коде.

const run = epoch
const response = await request({ signal: controller.signal })
if (run !== epoch) return
result.value = response

function resetWork() {
  epoch += 1
  controller.abort()
  controller = new AbortController()
}

Опыт 6. Загрузка карточки без потери пользовательского ввода

Нажмите «Загрузить клиента» и сразу начните печатать имя. Через небольшую задержку придут данные Алексея Сергеева, но они не заменят ваш текст. Мастерская покажет отдельный блок с предложением применить загруженную карточку или оставить текущие значения. Так человек сохраняет контроль над своей работой.

Если форма не изменялась, ответ можно принять как новые исходные значения через form.reset(data). Если изменения уже есть, автоматическое применение опасно. В демо мы проверяем isDirty и сравниваем снимок значений на старте запроса с текущими. Это также защищает ситуацию, когда поле изменилось во время ожидания.

Переключатель учебной ошибки позволяет увидеть неуспешную загрузку и повтор. Сброс во время ожидания отменяет действие, поэтому старый ответ не заполнит уже очищенную форму. Для большого приложения загрузку и кеш удобно поручить TanStack Query, сохранив явную политику применения данных к редактируемому документу. Базовый сценарий загрузки приведён в Async Initial Values.

const before = JSON.stringify(form.state.values)
const data = await loadCustomer()
if (form.state.isDirty || JSON.stringify(form.state.values) !== before) {
  pendingData.value = data
} else {
  form.reset(data)
}

Опыт 7. Сохранение и ответы 200, 422, 409, 500

Седьмой опыт начинается с заполненной карточки. Выберите 422 и отправьте её: сообщение появится рядом с почтой. Измените адрес — серверная ошибка для этого поля очистится. Затем выберите конфликт 409 или временный сбой 500. Они относятся ко всему действию, поэтому отображаются общим сообщением, а ввод остаётся на месте.

Ответ 422 полезен, когда сервер понимает JSON, но отвергает значения. Ответ 409 сообщает о конфликте состояния: например, другой оператор уже изменил запись. Временный сбой не требует очистки формы. В рабочем приложении тексты и возможность повтора должны учитывать реальную операцию; повтор платежа и повтор загрузки справочника имеют разные последствия.

В мастерской режим выбирается вручную специально для обучения. PHP-обработчик тоже поддерживает эти ограниченные демонстрационные ответы, но сначала выполняет настоящую серверную валидацию. При переносе в свой проект удалите ручной выбор статуса и связывайте ответ с реальным результатом проверки или транзакции.

for (const [name, error] of Object.entries(response.fields || {})) {
  form.setFieldMeta(name, meta => ({
    ...meta,
    isTouched: true,
    errorMap: { ...meta.errorMap, onServer: error },
  }))
}
// При изменении поля очищаем его onServer,
// сохраняя остальные правила и введённые значения.

Повторная отправка, доступность кнопки и версия записи

Пока выполняется обработчик onSubmit, кнопка отправки отключена и меняет текст. Это предотвращает случайные повторные нажатия и объясняет ожидание. Мы не блокируем возможность первого нажатия только потому, что форма пока не проверена: человек должен иметь возможность получить полный список ошибок и перейти к исправлению.

Отключённая кнопка защищает лишь один экран. Пользователь может открыть вторую вкладку, повторить запрос вручную или потерять соединение после успешного сохранения. Для важных действий серверу нужен собственный механизм защиты от дубликатов. Для редактирования полезны номер версии, ETag или другой договорённый способ обнаружения изменений.

В демо поле version показывает место такого значения в контракте, а ответ 409 позволяет потренировать интерфейс. Полноценный optimistic lock требует хранилища и атомарной проверки версии; PHP-пример без состояния этого не обещает. После конфликта реальная форма должна предложить загрузить актуальную запись, сравнить изменения и осознанно повторить сохранение.

<button type="submit"
  :disabled="state.isSubmitting || state.isValidating || loading">
  {{ state.isSubmitting ? 'Отправляем…' : 'Проверить и отправить' }}
</button>

Опыт 8. Заказ по шагам: проверка перед переходом

Восьмой интерфейс разделяет заявку на контакт, параметры услуги и итог. «Далее» проверяет только текущий шаг; «Назад» возвращает к прежним значениям. На последнем экране пользователь видит, что именно отправляет, и подтверждает состав учебной заявки. Это полезнее, чем длинная форма, в которой итог скрыт за прокруткой.

Важно не путать навигацию и отправку. Кнопки перехода имеют type=button и не вызывают случайный submit. Финальная кнопка запускает общую проверку всех значений. Данные предыдущих шагов остаются в модели формы, хотя соответствующие поля временно отсутствуют в разметке.

Количество шагов следует выбирать по смысловым группам. Разбивать три простых поля на три экрана обычно неудобно. А длинную заявку с контактами, услугой, участниками и проверкой состава разумно разделить. В нашем примере шаги всегда видны, поэтому человек понимает, где находится и сколько осталось до результата.

Переход защищён от повторного нажатия: пока проверяются поля, кнопка «Далее» отключена и повторный вызов обработчика ничего не делает. После ожидания мы сверяем поколение формы, чтобы сброс не завершился неожиданным переходом на следующий шаг. В исходнике есть и эта защита, и проверка исходного номера шага.

async function nextStep() {
  if (navigationBusy.value) return
  navigationBusy.value = true
  const run = epoch, from = step.value
  try {
    const fields = from === 0 ? ['name', 'email'] : ['service', 'people']
    for (const name of fields) {
      await form.validateField(name, 'blur')
      if (run !== epoch || step.value !== from) return
    }
    const errors = validate('wizard', form.state.values)
    if (fields.every(name => !errors[name])) step.value = from + 1
  } finally {
    if (run === epoch) navigationBusy.value = false
  }
}

Экспорт JSON, сброс и осмысленный результат действия

Экспорт помогает понять структуру формы без открытия инструментов разработчика. Пользователь получает обычный JSON с текущими значениями; счёт содержит массив позиций, профиль — вложенный контакт, заявка частного лица — только нужные поля. Файл можно использовать как основу тестовой фикстуры или примера запроса.

Снимок ввода и ответ сервера показаны раздельно. Это небольшое, но полезное различие: значение в поле ещё не означает успешную запись. При ошибке 422 текущий payload остаётся доступным, а результата принятой операции нет. Такое представление помогает разработчику не смешивать редактируемый черновик и подтверждённую запись.

Сброс в мастерской возвращает первоначальное состояние выбранного опыта, очищает результат и отменяет ожидание. В реальном редакторе иногда нужен другой смысл: вернуться к последней сохранённой версии. Назовите действие конкретно и при необходимости попросите подтвердить потерю большого объёма несохранённой работы. Для коротких учебных форм достаточно явной кнопки и понятного сообщения.

const blob = new Blob([JSON.stringify(payload, null, 2)], {
  type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'form-data.json'
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)

Доступность: label, ошибка, клавиатура и фокус

Форма должна оставаться понятной без мыши. У каждого поля есть связанный label; последовательность табуляции следует разметке. Ошибка связана с input через aria-describedby, а наличие ошибки отражено в aria-invalid. После неуспешной отправки фокус переходит к первому неверному полю, чтобы человек сразу мог продолжить работу.

Не стоит автоматически переводить фокус при каждом появлении асинхронного сообщения: это мешает набору текста. Для статуса ожидания достаточно небольшого индикатора и спокойного обновления. В нашей мастерской основные сообщения действия используют role=status, а ошибки полей становятся заметными рядом с вводом.

На телефоне двухколоночная форма превращается в последовательные блоки. Кнопки сохраняют удобную высоту, длинный JSON прокручивается внутри своего контейнера, а сам документ не должен расширяться за экран. Проверяйте не только красивый начальный экран, но и длинные ошибки, заполненные позиции счёта, открытый итог и состояние ожидания.

Как устроен PHP-обработчик из архива

PHP endpoint принимает только POST с application/json. Тело ограничено 16 КиБ, JSON разбирается с ограничением глубины, действие выбирается из списка check, load, save. В режиме save сервер знает восемь разрешённых схем и проверяет данные отдельно от браузерных правил. Неизвестные действия не превращаются в вызов произвольной функции.

Для результата используется разрешённый набор полей. Если посетитель добавит isAdmin, это свойство не попадёт в принятую запись. Счёт ограничен десятью позициями, ID строк не должны повторяться, количество должно быть целым, цена — находиться в допустимом диапазоне. Для календарных дат проверяется существование даты, а не только её внешний вид.

Обработчик намеренно не пишет в БД и не отправляет письма. Благодаря этому его можно запускать как учебный стенд без ключей и персональных данных. Проверка почты выполняется средствами PHP; описание фильтра доступно в официальном руководстве filter_var.

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    reply(405, ['message' => 'Используйте POST']);
}
$raw = file_get_contents('php://input', false, null, 0, 16385);
if ($raw === false || strlen($raw) > 16384) {
    reply(413, ['message' => 'Максимум 16 КиБ']);
}
$body = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);

Серверные правила и граница безопасности формы

Проверка в браузере помогает быстро исправить ввод, но её можно обойти. Сервер заново проверяет типы: строка «2» и число 2 не обязаны считаться одинаковыми. Он ограничивает размер и количество значений, отбрасывает лишние поля и возвращает конкретные пути ошибок. Удобный UI не заменяет этот слой.

Для рабочего приложения поверх примера понадобятся аутентификация и проверка прав на конкретную запись. Если используются cookie-сессии, добавьте защиту от CSRF. Секреты интеграции должны оставаться на сервере: переменные окружения, попадающие в клиентскую Vite-сборку, доступны посетителю. Проверка Origin в демо — лишь дополнительное ограничение, а не готовая система авторизации.

Продумайте журналы ошибок. Обычно для диагностики достаточно идентификатора запроса, кода результата и времени; полный payload может содержать персональные данные. Также решите, какие ошибки можно показывать человеку дословно, а какие следует заменить понятным сообщением и сохранить подробность только в серверном журнале.

Windows: запуск мастерской в PowerShell

Распакуйте архив в отдельный каталог и откройте терминал в tanstack-form-project. Установите поддерживаемую Node.js 24 LTS и проверьте версию. В PowerShell используем npm.cmd: это позволяет запускать пакетный файл npm без изменения политики выполнения PowerShell-скриптов.

После установки зависимостей соберите проект и выполните тесты. Команда start поднимает небольшой статический сервер только на 127.0.0.1:8510. Откройте адрес в браузере и пройдите несколько опытов. Остановка выполняется сочетанием Ctrl+C в том же терминале; существующие процессы и серверы на компьютере трогать не нужно.

Не открывайте index.html двойным щелчком через file://: модульная сборка рассчитана на HTTP. Если порт занят, задайте другой через переменную PORT. Содержимое public можно использовать для размещения статической версии, а исходники src нужны для изменения интерфейса и повторной сборки.

Set-Location .\tanstack-form-project
node --version
npm.cmd ci
npm.cmd run build
npm.cmd test
npm.cmd start
# http://127.0.0.1:8510/
# Другой порт:
$env:PORT = '8512'
npm.cmd start

Windows: настоящий HTTP-запрос к PHP

Установите PHP 8.2 или новее и убедитесь, что команда php доступна в терминале. Серверный пример не требует Composer и сторонних PHP-пакетов: используется стандартный JSON, фильтры и проверка дат. Его можно изучить отдельно от Vue и затем заменить своим backend.

Команда test:php сама выбирает свободный loopback-порт, запускает собственный PHP-процесс, выполняет HTTP-проверки и закрывает именно этот процесс. Для ручного опыта запустите router на 8511 и откройте мастерскую с параметром backend=php. Верхняя плашка изменится: теперь запросы check, load и save действительно идут по сети к PHP.

Встроенный PHP-сервер удобен для локального изучения, но не является конфигурацией промышленного размещения. Он не должен становиться публичным сервером вашего сайта. Ниже приведён отдельный путь через PHP-FPM и веб-сервер, где статические файлы и endpoint размещаются в ограниченном document root.

php --version
npm.cmd run test:php
php -S 127.0.0.1:8511 server/router.php
# Откройте http://127.0.0.1:8511/?backend=php

# В другом терминале:
$body = @{ action='check'; value='admin' } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:8511/api.php' -ContentType 'application/json' -Body $body

Linux: те же исходники и воспроизводимые команды

На Linux нужен тот же поддерживаемый Node.js и PHP CLI. Не переносите node_modules с Windows: установите зависимости из lock на целевой системе. Это особенно важно для инструментов сборки с платформенными пакетами. Сам исходный проект и зафиксированные версии остаются общими.

Для Debian или Ubuntu PHP CLI можно установить из репозитория выбранного дистрибутива, предварительно проверив доступную версию. Команды npm ci, build и test выполняются обычным пользователем. Не используйте sudo для повседневной установки зависимостей проекта: права администратора нужны для системных пакетов и конфигурации сервера, а не для каждого запуска сборки.

Если стенд работает на удалённой машине, оставьте dev-сервер на loopback и откройте его через SSH-туннель. Так можно проверить интерфейс в своём браузере без публикации временного порта в интернет. После опыта остановите собственный сервер и закройте туннель.

sudo apt update
sudo apt install php-cli
node --version
php --version
npm ci
npm run build
npm test
npm run test:php
php -S 127.0.0.1:8511 server/router.php
# На своём компьютере:
ssh -L 8511:127.0.0.1:8511 user@server

Linux/VPS: размещаем готовую сборку и PHP-FPM

Для постоянного размещения соберите проект заранее и отдавайте содержимое public через веб-сервер. Node.js не обязан постоянно работать на VPS: в данном проекте он нужен для сборки, а интерфейс выполняется в браузере. PHP-FPM обслуживает только endpoint, если вам нужен полный серверный режим.

Создайте отдельный каталог сайта и скопируйте туда готовую сборку. Затем установите server/api.php в public/api.php. Корень исходного проекта, node_modules и локальный router не должны стать document root. Пользователю веб-сервера достаточно права чтения файлов; этот учебный endpoint не требует каталога для записи.

В примерах конфигурации указан сокет PHP 8.3 FPM. На вашем сервере версия и путь могут отличаться: проверьте содержимое /run/php/ и состояние установленного сервиса. Права на сокет тоже важны — Caddy или Nginx должен иметь возможность подключиться к FPM. Эта настройка относится к конкретному дистрибутиву и установленным пользователям.

sudo install -d -m 755 /var/www/form-demo/public
sudo cp -a public/. /var/www/form-demo/public/
sudo install -m 644 server/api.php /var/www/form-demo/public/api.php
ls /run/php/
sudo systemctl status php8.3-fpm --no-pager
# Каталоги 755, файлы 644; запись приложению не требуется.

Caddy: HTTPS и единственный PHP endpoint

В архиве есть Caddyfile для домена forms.example.com. Замените домен своим, настройте DNS и проверьте доступность портов 80 и 443. Для автоматического выпуска сертификата серверу нужны корректное время и исходящий доступ к центру сертификации. После изменения конфигурации сначала выполните validate, затем reload.

Маршрут api.php передаётся в PHP-FPM, остальные запросы обслуживаются как статические файлы. Ограничение размера тела задано и в веб-сервере, и в PHP. Это не дублирование ради количества настроек: внешний слой останавливает слишком большой запрос раньше, а PHP сохраняет проверку при запуске за другим прокси.

Откройте сайт с параметром backend=php и выполните проверку имени. Если статика работает, а API возвращает 502, смотрите состояние FPM, путь сокета и права доступа. Если приходит 404, проверьте, что endpoint действительно установлен в document root. Не исправляйте такую проблему публикацией всего исходного каталога.

forms.example.com {
    root * /var/www/form-demo/public
    request_body {
        max_size 16KiB
    }
    @api path /api.php
    handle @api {
        php_fastcgi unix//run/php/php8.3-fpm.sock
    }
    @otherPhp path *.php
    handle @otherPhp {
        respond 404
    }
    handle {
        file_server
    }
    log
}

Nginx, диагностика и перенос в существующий сайт

Если на VPS уже используется Nginx, не нужно менять сервер только ради этого примера. В архиве есть минимальный HTTP-виртуальный хост: статические файлы обслуживаются напрямую, а точный маршрут /api.php передаётся в FPM. Другие PHP-пути возвращают 404. Для публичного домена дополните конфигурацию реальными TLS-сертификатами и редиректом на HTTPS.

После копирования файлов проверьте загрузку JavaScript и CSS, затем отдельный POST к endpoint. Ошибка MIME у модуля часто означает, что сервер вместо JS вернул HTML-страницу ошибки. Неправильный базовый путь проявляется 404 у assets. В нашем Vite-конфиге base=’./’, поэтому сборка может жить в подкаталоге; путь API при этом должен оставаться согласованным с местом размещения.

В существующем сервисе заменяйте фикстуру постепенно. Сначала сохраните контракт ошибок и одну форму, затем подключите авторизацию, реальные данные и обработку версии. Не переносите демонстрационный выбор ответа 500 в рабочую панель. Порядок сборки статического приложения описан в документации Vite по размещению.

location = /api.php {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root/api.php;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ \.php$ { return 404; }
location / { try_files $uri $uri/ =404; }

Проверки проекта: что действительно выполняется

В архиве есть проверки правил и настоящих FormApi/FieldApi: неверные значения не вызывают отправку, исправление позволяет продолжить, перемещение массива сохраняет идентичность позиции. Отдельный тест загружает собранное Vue-приложение в JSDOM и проходит восемь интерфейсов, включая устаревший ответ, сохранение ввода и сброс во время ожидания.

HTTP-проверка запускает PHP и делает реальные запросы. Она проверяет восемь схем, ответы 422/409/500, неверный JSON, метод, Content-Type, размер тела, чужой Origin, невозможную дату и лишнее поле isAdmin. Это помогает отличить красивый клиентский пример от обработчика, который действительно проверяет данные.

JSDOM не доказывает правильность визуального отображения на телефоне. Для этого нужна отдельная браузерная проверка ширины, прокрутки, фокуса и длинных сообщений. Аналогично запуск на Linux в контейнере не доказывает, что на конкретном внешнем VPS правильно настроены DNS, сертификаты и FPM. Эти границы полезно сохранять и в собственном процессе выпуска.

npm run build
npm test
npm run test:php
# Проверка синтаксиса PHP отдельно:
php -l server/api.php
php -l server/router.php

Типичные ошибки при переносе в свою форму

Первый частый сбой — значение меняется в input, но библиотека о нём не знает. Проверьте, что обработчик вызывает handleChange и что blur тоже передаётся полю. Второй — две копии состояния: одна в TanStack Form, другая в отдельном reactive-объекте. Выберите один источник значений, а вычисляемые данные получайте из него.

Другой класс проблем связан с событиями проверки. Сообщение onBlur может оставаться актуальным до следующей соответствующей проверки, поэтому для зависимых значений важно продумать, какое событие пересчитывает правило. Не добавляйте одинаковую проверку во все события без понимания, как очищаются её сообщения.

При работе с API внимательно проверяйте срок жизни запроса. Старый ответ не должен менять новую форму, а сброс не должен неожиданно завершаться сообщением «Сохранено». Сохраняйте введённый текст при сетевой ошибке, очищайте серверную ошибку конкретного поля после изменения и показывайте общий конфликт отдельно от ошибок значений.

Как развить мастерскую в рабочий экран

Начните с одного законченного сценария: например, редактирования клиента. Возьмите компонент поля, определите начальные значения и контракт endpoint, затем добавьте проверку пустых и неверных данных. Только после этого подключайте условные разделы, массивы и несколько шагов. Так каждое усложнение будет опираться на уже работающий путь.

Для загрузки, кеширования и повторного получения данных удобно использовать материал про TanStack Query. Для больших справочников или таблиц рядом с формой пригодится TanStack Virtual. Если схема данных становится общей для нескольких сервисов, вынесите её в отдельный слой валидации; но не смешивайте техническую схему с текстами подсказок для пользователя.

Сохраните в проекте сценарии проверки, а не только красивое начальное состояние: пустая форма, длинная ошибка, медленный ответ, повтор, конфликт и возврат назад. Именно эти состояния чаще всего определяют, будет ли оператор доверять интерфейсу в ежедневной работе.

Исходники, структура архива и официальные материалы

В ZIP входят исходные Vue-компоненты, модель правил, локальная API-фикстура, PHP endpoint, router для разработки, тесты, конфигурации Caddy и Nginx, lock-файл, README и готовая сборка. Внутри нет node_modules, секретов или пользовательской базы. Установите зависимости командой npm ci, если хотите менять и пересобирать проект.

Основные файлы читаются независимо: App.vue отвечает за оболочку, ScenarioForm.vue — за восемь сценариев, InputField.vue — за общий ввод и ошибки, model.js — за правила и демонстрационный API. Собственные примеры распространяются под MIT; лицензии сторонних пакетов собраны рядом со сборкой.

Используйте мастерскую как проверяемую отправную точку. Перед переносом в рабочий сервис замените фикстуру реальным контрактом, добавьте права и хранилище, затем ещё раз пройдите ошибки и мобильные состояния. Ссылки ниже ведут к первичным материалам, где можно сверить API при обновлении версий.