Формы в 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 строк: начнём честно
Вы открываете новый проект, создаёте первый компонент — и через 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.Минусы: Типобезопасность — номинальная.
Formik — уходящая эпоха
34K+ звёзд, но тренд на спад: 3M скачиваний vs 15M у RHF.
Подход: controlled-компоненты,
<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)}
/>
)}
/>
Плюсы:
Conform — серверный подход
2.5K звёзд. Для Remix/Next.js: progressive enhancement, работает без JavaScript.
react-jsonschema-form — конфиг-машина
Генерирует формы из JSON Schema. Мощно, но негибко: кастомизация — боль.
Сводная таблица: 9 проблем и кто их решает
| Проблема | RHF | Formik | TanStack | Conform | RJSF | @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:
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>
))}
А теперь представьте, что таких путей в форме 30. Каждый — конкатенация из 3-5 сегментов с индексами. Добро пожаловать в отладку.
7. Консистентность стилей — ручная обвязка каждого поля
Каждое поле формы — это не просто
// Пишете это для КАЖДОГО поля. Каждого.
<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-ом в свой
Генри Форд говорил: «Клиент может получить автомобиль любого цвета — при условии, что этот цвет будет чёрным». В формах работает тот же принцип: настоящая консистентность появляется только тогда, когда обвязка не копируется руками, а зашита в компоненты. Все 40 полей в
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>
}
Три
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>
И это одно поле. А ещё нужно:
- Фокус на первое поле с ошибкой после неудачного сабмита
На практике разработчики добавляют
Что, если формы можно строить иначе?
Мы задались вопросом: а что, если бы:
- Валидация и 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.
А вот форма заказа с вложенностью — сравните с кодом из проблемы #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>
Ни одного строкового пути с точками и индексами. Вложенность — через компоненты. Переименовали
Это
Под капотом: почему TanStack Form, а не React Hook Form
Когда мы переписали 10 приложений с Conform на TanStack Form, решающими стали три вещи:
Фреймворк-агностичное ядро — TanStack Form не зависит от React internals. Это значит, что наш compound-component API (
Subscription-based рендеринг — каждое поле подписывается только на своё значение и ошибки. При вводе в поле "email" поле "password" не ре-рендерится. React Hook Form достигает этого через
Мы обожглись на Formik (ре-рендеры убивали формы с 20+ полями), на Conform (progressive enhancement не нужен в SPA), и остановились на TanStack Form как "правильном фундаменте, но без батареек". Батарейки — наши.
Что будет в цикле
Это первая статья из 12. В следующих мы разберём:
- Формы в React — почему всё ещё больно (вы здесь)Zod
Каждая статья самодостаточна, с живыми примерами на 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 до продакшена?