Перейти к содержимому

Назад к блогу

Featuredreactформыtypescriptopen-source

Формы в React: 2 поля = 48 строк. Почему в 2026 это всё ещё боль?

Логин-форма на vanilla React — 48 строк, на @letar/forms — 8. Разбираем 9 повторяющихся проблем с формами, которые ни одна из 5 популярных библиотек не решает полностью.

9 мая 2026 г.

--TL;DR:

    Логин-форма на vanilla React — 48 строк. На @letar/forms — 8. Функциональность одинаковая.9 повторяющихся проблем с формами не решает ни одна из 5 популярных библиотек полностью.Декларативный подход (схема = источник правды) устраняет большинство из них.

Кому полезно:

    Junior: поймёте, почему ваши формы разрастаются и как этого избежатьMiddle: сравните подходы 5 библиотек и увидите, что у каждой есть слабое местоSenior: оцените архитектуру Compound Components + Zod .meta() как альтернативу JSON-конфигам

Первая статья из цикла «@letar/forms — от боли к декларативным формам». 13 статей о том, как мы построили form-библиотеку на 50+ компонентов, отделили вёрстку от логики и пришли к open-source.


2 поля, 48 строк: начнём честно

До и после: форма логина на vanilla React vs @letar/forms

Вы открываете новый проект, создаёте первый компонент — и через 15 минут уже пишете форму. Email, пароль. Два поля. Казалось бы, что может пойти не так?

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [errors, setErrors] = useState({})
  const [isSubmitting, setIsSubmitting] = useState(false)

  const validate = () => {
    const newErrors = {}
    if (!email) newErrors.email = 'Обязательное поле'
    else if (!/\S+@\S+/.test(email)) newErrors.email = 'Некорректный email'
    if (!password) newErrors.password = 'Обязательное поле'
    else if (password.length < 8) newErrors.password = 'Минимум 8 символов'
    return newErrors
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    const validationErrors = validate()
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      return
    }
    setIsSubmitting(true)
    try {
      await api.login({ email, password })
    } catch (err) {
      setErrors({ form: err.message })
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      <div>
        <label htmlFor="password">Пароль</label>
        <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      {errors.form && <div className="error">{errors.form}</div>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Загрузка...' : 'Войти'}
      </button>
    </form>
  )
}

48 строк на два поля. И это без:

    Валидации на blurПоказа/скрытия пароляAccessibility (aria-атрибуты)Типизации TypeScriptСброса формы после отправкиОбработки серверных ошибок на конкретных полях

Добавьте всё это — и вы легко перешагнёте за 100 строк. На два поля.

А теперь представьте форму из 20 полей. С условным отображением. С массивами. С мультистепом. С оффлайн-режимом. Каждый, кто работал с формами в продакшене, знает это чувство: «Ну не может же быть, что в 2026 году нет нормального решения».


Ландшафт: 5 библиотек, 5 компромиссов

React не даёт форм как first-class примитив. Это создало экосистему библиотек, каждая из которых решает проблему по-своему:

React Hook Form — король по инерции

44K+ звёзд на GitHub, 15M+ скачиваний в неделю.

Подход: uncontrolled-компоненты, минимальные ре-рендеры,

+
.

const { register, handleSubmit, formState: { errors } } = useForm()

<input {...register('email', { required: true, pattern: /\S+@\S+/ })} />
{errors.email && <span>Некорректный email</span>}

Плюсы: Производительность, огромное сообщество, Zod-интеграция через resolver.Минусы: Типобезопасность — номинальная.

(опечатка) — рантайм-ошибка, не компиляторная. API строковых имён полей — наследие эпохи до TypeScript-first.

Formik — уходящая эпоха

34K+ звёзд, но тренд на спад: 3M скачиваний vs 15M у RHF.

Подход: controlled-компоненты,

+
, Yup-валидация.

<Formik initialValues={{ email: '' }} validationSchema={yupSchema} onSubmit={save}>
  <Form>
    <Field name="email" type="email" />
    <ErrorMessage name="email" />
  </Form>
</Formik>

Плюсы: Простой ментальный модель, хорошая документация.Минусы: Ре-рендеры на каждый keypress, не обновлялся активно, Yup вместо Zod.

TanStack Form — новая надежда

6K+ звёзд, быстро растёт. +8 позиций год-к-году в рейтингах.

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

const form = useForm({
  defaultValues: { email: '' },
  onSubmit: async ({ value }) => { /* ... */ },
})

<form.Field
  name="email"
  children={(field) => (
    <input
      value={field.state.value}
      onChange={(e) => field.handleChange(e.target.value)}
    />
  )}
/>

Плюсы:

— опечатка в имени поля = ошибка компиляции. Работает с React, Vue, Solid, Angular.Минусы: Молодая экосистема, verbose API (каждое поле — render-prop), кривая обучения.

Conform — серверный подход

2.5K звёзд. Для Remix/Next.js: progressive enhancement, работает без JavaScript.

react-jsonschema-form — конфиг-машина

Генерирует формы из JSON Schema. Мощно, но негибко: кастомизация — боль.

Сводная таблица: 9 проблем и кто их решает

ПроблемаRHFFormikTanStackConformRJSF@letar/forms
1. Дублирование валидации~~~~++
2. Бойлерплейт++~+++
3. Ре-рендеры+-++~+
4. Типобезопасность~-+~-+
5. Расширяемость+++~~+
6. Вложенные пути~~+~++
7. Консистентность стилей----++
8. Submit-состояние~~++~+
9. Accessibility---~~+

решает,
частично,
не решает

<details><summary>Что такое Compound Components (если не знакомы)</summary>

Compound Components — паттерн React, при котором родительский компонент управляет состоянием, а дочерние компоненты используют это состояние через Context. Пример из стандартной библиотеки —

и
:
хранит выбранное значение,
знает, выбран ли он.

В @letar/forms это

(хранит состояние формы) и
,
и т.д. (обращаются к нему через контекст). Подробнее — в статье про архитектуру.

</details>


Девять проблем, которые никто не решил целиком

Каждая библиотека сильна в 2-3 аспектах, но ни одна не закрывает все девять:

1. Дублирование валидации

Вот типичный код с React Hook Form + Zod:

// schema.ts — источник правды для валидации
const schema = z.object({
  email: z.string().email('Некорректный email'),
  name: z.string().min(2, 'Минимум 2 символа').max(100),
})

// form.tsx — но label, placeholder, helperText живут тут
<Controller
  name="email"
  control={control}
  render={({ field, fieldState }) => (
    <FormControl isInvalid={!!fieldState.error}>
      <FormLabel>Email</FormLabel>           {/* ← дублирование */}
      <Input
        placeholder="user@example.com"       {/* ← дублирование */}
        type="email"                         {/* ← знаем из z.email(), но пишем вручную */}
        maxLength={100}                      {/* ← знаем из z.max(100), но пишем вручную */}
        {...field}
      />
      <FormHelperText>Используется для входа</FormHelperText>  {/* ← дублирование */}
      <FormErrorMessage>{fieldState.error?.message}</FormErrorMessage>
    </FormControl>
  )}
/>

Три источника правды:

    Zod: правила валидацииJSX: label, placeholder, helperTextHTML-атрибуты: type, maxLength, min, max

Измените лимит в Zod — забудете обновить

. Или наоборот. Рассинхронизация неизбежна.

2. Boilerplate

20% разработчиков называют главной проблемой форм избыточную сложность, ещё 15% — boilerplate. Каждое поле — это:

    LabelInput с биндингамиОбработка ошибокHelper textWrapper для стилей

Для формы из 15 полей это 150-200 строк только на обвязку. Бизнес-логики — 0.

3. Ре-рендеры

Controlled-компоненты (Formik, ванильный React) перерисовывают всю форму при каждом нажатии клавиши. На форме с 15-20 полями это заметный лаг. Uncontrolled (RHF) решают проблему, но ценой менее предсказуемого поведения.

4. Типобезопасность

RHF:

— тишина до рантайма.TanStack Form решает это через
, но требует verbose render-props.Идеал: компиляторная проверка имён полей без лишнего кода.

5. Расширяемость

Вам нужно поле «выбор города с автоподсказками из DaData». Или «расписание на неделю». Или «drag & drop массив с вложенными группами». Каждая библиотека скажет: «напишите кастомный компонент». Но интеграция с формой, валидацией, типами, стилями — это ваша проблема.

6. Вложенные пути — строковый ад

В реальном приложении данные вложенные. Заказ с клиентом, адресом и массивом товаров:

// React Hook Form: всё на строках
const { register, control } = useForm<Order>()

// Три уровня вложенности — ручной путь
<input {...register('customer.address.city')} />
<input {...register('customer.address.street')} />

// Массив — ещё веселее
const { fields } = useFieldArray({ control, name: 'items' })

{fields.map((item, index) => (
  <div key={item.id}>
    <input {...register(`items.${index}.product`)} />
    <input {...register(`items.${index}.quantity`)} />
    {/* А если у товара есть вложенные варианты? */}
    <input {...register(`items.${index}.variants.${variantIdx}.size`)} />
    <input {...register(`items.${index}.variants.${variantIdx}.color`)} />
    <input {...register(`items.${index}.variants.${variantIdx}.price`)} />
  </div>
))}

— это не код, это заклинание. Одна опечатка в любом сегменте — и поле молча перестаёт работать. TypeScript не спасёт:
принимает
, а не типизированный путь. Переименовали
в
на бэкенде? Удачи с глобальным поиском по шаблонным строкам.

А теперь представьте, что таких путей в форме 30. Каждый — конкатенация из 3-5 сегментов с индексами. Добро пожаловать в отладку.

7. Консистентность стилей — ручная обвязка каждого поля

Каждое поле формы — это не просто

. Это обвязка: label сверху, инпут посередине, ошибка снизу, подсказка, иконка, звёздочка обязательности. И эта обвязка должна быть одинаковой для всех полей в приложении.

// Пишете это для КАЖДОГО поля. Каждого.
<FormControl isInvalid={!!errors.email} isRequired>
  <FormLabel htmlFor="email">Email</FormLabel>
  <InputGroup>
    <InputLeftElement>
      <EmailIcon />
    </InputLeftElement>
    <Input id="email" {...register('email')} placeholder="user@example.com" />
  </InputGroup>
  {errors.email
    ? <FormErrorMessage>{errors.email.message}</FormErrorMessage>
    : <FormHelperText>Используется для входа</FormHelperText>}
</FormControl>

// Теперь повторите для password, name, phone, address...
// 15 полей × 12-15 строк обвязки = 200 строк CSS-шаблона

Обычно это решают extract-ом в свой

компонент. Но тогда вы сами становитесь автором form-библиотеки — с пропсами, edge-кейсами, и обязанностью поддерживать всё это. Любой новый дизайн-элемент (иконка слева? бейдж «new»? tooltip на label?) — это правка общего компонента, которая может сломать 50 форм.

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

рендерят label, ошибку, подсказку и aria-атрибуты через единый внутренний layout на Chakra UI. Вы не выбираете как обернуть поле — вы получаете одинаковый, протестированный результат. Любой цвет, если это чёрный.

8. Состояние отправки — isSubmitting, disabled, loading

Кнопка «Отправить» — проще простого? На самом деле — целый стейт-машина:

const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [isSuccess, setIsSuccess] = useState(false)

const handleSubmit = async (data: FormValues) => {
  setIsSubmitting(true)
  setSubmitError(null)
  setIsSuccess(false)
  try {
    await api.save(data)
    setIsSuccess(true)
    reset() // сброс формы
  } catch (err) {
    if (err.status === 422) {
      // Серверные ошибки валидации — раскидать по полям
      const fieldErrors = err.body.errors
      Object.entries(fieldErrors).forEach(([field, message]) => {
        setError(field, { message }) // если RHF
      })
    } else {
      setSubmitError(err.message)
    }
  } finally {
    setIsSubmitting(false)
  }
} // В JSX:
<button type="submit" disabled={isSubmitting} className={isSubmitting ? 'btn-loading' : 'btn-primary'}>
  {isSubmitting
    ? (
      <>
        <Spinner size="sm" /> Отправка...
      </>
    )
    : isSuccess
    ? (
      <>
        <CheckIcon /> Отправлено!
      </>
    )
    : (
      'Сохранить'
    )}
</button>
{
  submitError && <div className="error">{submitError}</div>
}

Три

, try-catch-finally, условный рендеринг в кнопке, маппинг серверных ошибок по полям. И это каждая форма в приложении. Скопировали, забыли
в
— кнопка заблокирована навсегда. Не обработали 422 — пользователь не видит, какое поле сервер отверг. А ещё бывает двойной сабмит, если не заблокировать кнопку вовремя.

9. Accessibility — то, что все «сделают потом»

Формы — самый интерактивный элемент интерфейса, и именно на них accessibility ломается чаще всего. Вот что нужно сделать правильно для каждого поля:

// Одно поле «по стандарту» — сколько деталей вы бы забыли?
<div role="group" aria-labelledby="email-label">
  <label id="email-label" htmlFor="email-input">
    Email
    <span aria-hidden="true">*</span>
  </label>
  <input
    id="email-input"
    type="email"
    aria-required="true"
    aria-invalid={!!errors.email}
    aria-describedby={
      errors.email ? 'email-error' : 'email-hint' // переключаем в зависимости от состояния
    }
    value={email}
    onChange={handleChange}
  />
  {errors.email
    ? (
      <span id="email-error" role="alert">
        {errors.email}
      </span>
    )
    : <span id="email-hint">Используется для входа</span>}
</div>

и
должны совпадать.
указывает то на подсказку, то на ошибку — в зависимости от состояния валидации.
синхронизирован с
.
на ошибке, чтобы скринридер озвучил её при появлении. Звёздочка обязательности скрыта от скринридера через
, потому что уже есть
.

И это одно поле. А ещё нужно:

    Фокус на первое поле с ошибкой после неудачного сабмита
    на области с общей ошибкой формыУникальные
    для каждого поля (а если форма рендерится дважды на странице?)

На практике разработчики добавляют

и считают accessibility сделанным. Всё остальное — «потом». Потом не наступает.


Что, если формы можно строить иначе?

Мы задались вопросом: а что, если бы:

    Валидация и UI метаданные жили в одном месте — в Zod-схемеJSX содержал только вёрстку — ни label, ни placeholder, ни maxLength50+ готовых полей покрывали 95% сценариев — с единой CSS-обвязкой из коробкиВложенные объекты описывались компонентами, а не строковыми путямиКнопка Submit сама знала про isLoading, disabled и ошибкиAccessibility была встроена в каждое поле — aria-атрибуты, фокус, ролиФорму можно было сгенерировать из одной строки — из той же Zod-схемыТипобезопасность была бы встроенной, а не добавленнойЗащита от ботов не требовала отдельного пакета и настройки CAPTCHA

Вот как выглядит та же форма логина:

const LoginSchema = z.object({
  email: z.string().email().meta({
    ui: { title: 'Email', placeholder: 'user@example.com' },
  }),
  password: z.string().min(8).meta({
    ui: { title: 'Пароль' },
  }),
})

// Вариант 1: ручная вёрстка (полный контроль)
<Form schema={LoginSchema} initialValue={{ email: '', password: '' }} onSubmit={login}>
  <Form.Field.String name="email" />
  <Form.Field.Password name="password" />
  <Form.Button.Submit>Войти</Form.Button.Submit>
</Form>

// Вариант 2: автогенерация (одна строка)
<Form.FromSchema schema={LoginSchema} initialValue={data} onSubmit={login} submitLabel="Войти" />

6 строк схемы + 5 строк JSX. Или 1 строка, если автогенерация. Готовый рецепт формы логина — на forms-example.letar.best/examples/recipes.

    — извлекается из
    автоматически
    — из
    автоматическиLabel, placeholder — из
    , не из JSXОшибки — показываются автоматически, на русскомTypeScript —
    = ошибка компиляцииОбвязка — label, error, helperText — всё внутри
    , одинаково вездеAccessibility
    ,
    ,
    , уникальные
    , фокус на ошибку — из коробки, без единой строчки ручного кодаВложенность
    вместо
    Submit
    сам отключается при отправке и показывает спиннерЗащита от ботов — три уровня из коробки: невидимый
    (ловушка для ботов),
    (троттлинг повторных сабмитов),
    (Turnstile/reCAPTCHA) — без внешних зависимостей

А вот форма заказа с вложенностью — сравните с кодом из проблемы #6:

<Form schema={OrderSchema} initialValue={data} onSubmit={save}>
  <Form.Group name="customer">
    <Form.Field.String name="name" />
    <Form.Field.Phone name="phone" />
    <Form.Group name="address">
      <Form.Field.String name="city" /> {/* → customer.address.city */}
      <Form.Field.String name="street" /> {/* → customer.address.street */}
    </Form.Group>
  </Form.Group>

  <Form.Group.List name="items">
    {' '}
    {/* динамический массив */}
    <Form.Field.String name="product" /> {/* → items[0].product */}
    <Form.Field.Number name="quantity" /> {/* → items[0].quantity */}
  </Form.Group.List>

  <Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>

Ни одного строкового пути с точками и индексами. Вложенность — через компоненты. Переименовали

? Одно место, одно изменение.

Это

— библиотека, которую мы строили два года внутри монорепозитория для 10+ продакшн-приложений. 233 файла, 29 366 строк кода, 1 077 тестов.


Под капотом: почему TanStack Form, а не React Hook Form

Когда мы переписали 10 приложений с Conform на TanStack Form, решающими стали три вещи:

    — TanStack Form единственный даёт ошибку компиляции при опечатке в имени поля. React Hook Form использует
    , но в сложных вложенных типах он ломается.

    Фреймворк-агностичное ядро — TanStack Form не зависит от React internals. Это значит, что наш compound-component API (

    ) живёт над TanStack Form, а не хачит его. Обновление TanStack не ломает наш код.

    Subscription-based рендеринг — каждое поле подписывается только на своё значение и ошибки. При вводе в поле "email" поле "password" не ре-рендерится. React Hook Form достигает этого через

    , но ценой потери controlled-компонентов.

Мы обожглись на Formik (ре-рендеры убивали формы с 20+ полями), на Conform (progressive enhancement не нужен в SPA), и остановились на TanStack Form как "правильном фундаменте, но без батареек". Батарейки — наши.


Что будет в цикле

Это первая статья из 12. В следующих мы разберём:

    Формы в React — почему всё ещё больно (вы здесь)Zod
    — одна схема для валидации и UI
    — как единый источник правды убирает дублированиеCompound Components vs конфиг-объекты — почему
    , а не JSON50+ полей: от String до CreditCard — обзор всех field-компонентовМультистеп формы и условный рендеринг — Steps, When, валидация по шагамМассивы и вложенные объекты — FormGroup, drag & dropFromSchema: генерируем форму из одной строки — автогенерация из ZodОт БД до формы за 5 минут — ZenStack → Zod → React pipelineOffline-first формы — IndexedDB, очередь синхронизации, PWAi18n: формы на любом языке — мультиязычность и перевод ошибокMCP: как AI пишет формы за тебя — AI-интерфейс к библиотекеРелиз: как мы опенсорсили form-библиотеку — GitHub, npm, уроки

Каждая статья самодостаточна, с живыми примерами на forms-example.letar.best.



Попробовать

<details><summary>Установка</summary>

bun add @letar/forms
import { Form } from '@letar/forms'

const LoginSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
})

<Form schema={LoginSchema} initialValue={{ email: '', password: '' }} onSubmit={login}>
  <Form.Field.String name="email" />
  <Form.Field.Password name="password" />
  <Form.Button.Submit>Войти</Form.Button.Submit>
</Form>

</details>


Навигация по серии→ Следующая: Zod .meta() — одна схема для валидации, UI и доступности


Какую библиотеку форм вы используете в 2026? Доросли ли нативные формы React до продакшена?

Комментарии (0)

Войдите, чтобы оставить комментарий. Войти

Пока нет комментариев. Будьте первым!