i18n в React-формах: русские падежи, RTL и 120 переводов
i18n ключи в Zod .meta() + глобальный z.config({ customError }) дают полную локализацию форм без изменения компонентов. Русские plural forms через ICU Message Format.
18 мая 2026 г.
--TL;DR:
- i18n ключи в Zod
Кому полезно:
- Junior: понять как переводить label, placeholder и ошибки валидации через i18n ключи в
Десятая статья из цикла «@letar/forms — от боли к декларативным формам». Как перевести label, placeholder, ошибки валидации и подсказки — сохранив Zod-схему как единый источник правды.
Проблема: переведи всё
Мультиязычное приложение. Форма профиля — 10 полей. Для каждого нужно перевести:
- LabelPlaceholderHelper text (подсказка)Ошибки валидации (5-10 разных сообщений на поле)
10 полей × 4 текста × 3 языка = 120 строк переводов. И это одна форма.
Подход: i18n ключи в
Мы уже показали, как
import { z } from 'zod/v4'
const ProfileSchema = z.object({
name: z
.string()
.min(2)
.meta({
ui: {
title: 'profile.name.label', // i18n ключ
placeholder: 'profile.name.placeholder',
description: 'profile.name.hint',
},
}),
email: z
.string()
.email()
.meta({
ui: {
title: 'profile.email.label',
placeholder: 'profile.email.placeholder',
},
}),
})
Файлы переводов (next-intl):
// messages/ru.json
{
"profile": {
"name": {
"label": "Имя",
"placeholder": "Иван Иванов",
"hint": "Как к вам обращаться"
},
"email": {
"label": "Email",
"placeholder": "user@example.com"
}
}
}
// messages/en.json
{
"profile": {
"name": {
"label": "Name",
"placeholder": "John Doe",
"hint": "How should we address you"
},
"email": {
"label": "Email",
"placeholder": "user@example.com"
}
}
}
Библиотека автоматически определяет, что значение в
Перевод ошибок валидации
Zod v4 поддерживает кастомные сообщения:
const Schema = z.object({
name: z
.string({
error: (issue) => {
if (issue.input === undefined) return t('validation.required')
if (issue.code === 'too_small') return t('validation.minLength', { min: issue.minimum })
return t('validation.invalid')
},
})
.min(2),
email: z.string().email(t('validation.invalidEmail')),
})
Или через глобальный error map:
import { z } from 'zod/v4'
// Подключается один раз в провайдере
z.config({
customError: (issue) => {
const t = getTranslator() // next-intl
switch (issue.code) {
case 'invalid_type':
return t('zod.invalidType', { expected: issue.expected })
case 'too_small':
return t('zod.tooSmall', { minimum: issue.minimum })
case 'too_big':
return t('zod.tooBig', { maximum: issue.maximum })
case 'invalid_string':
if (issue.validation === 'email') return t('zod.invalidEmail')
if (issue.validation === 'url') return t('zod.invalidUrl')
return t('zod.invalidString')
default:
return t('zod.invalid')
}
},
})
// messages/ru.json
{
"zod": {
"invalidType": "Ожидается {expected}",
"tooSmall": "Минимум {minimum} символов",
"tooBig": "Максимум {maximum} символов",
"invalidEmail": "Некорректный email",
"invalidUrl": "Некорректный URL",
"invalidString": "Некорректное значение",
"invalid": "Некорректное значение"
}
}
Теперь все ошибки Zod автоматически на нужном языке.
Интеграция с next-intl
// providers.tsx
import { NextIntlClientProvider } from 'next-intl'
function Providers({ children, locale, messages }) {
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<ChakraProvider>{children}</ChakraProvider>
</NextIntlClientProvider>
)
}
// Форма автоматически использует текущую локаль
<Form schema={ProfileSchema} initialValue={data} onSubmit={save}>
<Form.Field.String name="name" /> {/* label: "Имя" / "Name" */}
<Form.Field.String name="email" /> {/* label: "Email" */}
<Form.Button.Submit>
{t('common.save')} {/* "Сохранить" / "Save" */}
</Form.Button.Submit>
</Form>
Переключение языка на лету
function LanguageSwitcher() {
const router = useRouter()
const locale = useLocale()
return (
<Form.Field.SegmentedGroup
name="locale"
options={[
{ value: 'ru', label: '🇷🇺 Русский' },
{ value: 'en', label: '🇬🇧 English' },
{ value: 'tr', label: '🇹🇷 Türkçe' },
]}
value={locale}
onChange={(newLocale) => router.push(`/${newLocale}/profile`)}
/>
)
}
При смене языка:
- Label и placeholder перерендериваются из
Plural forms: «1 символ, 2 символа, 5 символов»
Русский язык — один из самых сложных для плюрализации. Три формы вместо двух:
| Число | Английский | Русский |
|---|---|---|
| 1 | 1 character | 1 символ |
| 2 | 2 characters | 2 символа |
| 5 | 5 characters | 5 символов |
| 21 | 21 characters | 21 символ |
next-intl поддерживает ICU Message Format:
// messages/ru.json
{
"zod": {
"tooSmall": "Минимум {minimum, plural, one {# символ} few {# символа} many {# символов} other {# символов}}"
}
}
// messages/en.json
{
"zod": {
"tooSmall": "Minimum {minimum, plural, one {# character} other {# characters}}"
}
}
В глобальном error map:
z.config({
customError: (issue) => {
if (issue.code === 'too_small') {
return t('zod.tooSmall', { minimum: issue.minimum })
}
// ...
},
})
Результат: «Минимум 2 символа», «Минимум 5 символов», «Minimum 2 characters» — всё автоматически.
Перевод options в Select и RadioGroup
Опции выбора тоже нужно переводить. Два подхода:
Подход 1: i18n ключи в options
const Schema = z.object({
role: z.enum(['admin', 'user', 'guest']).meta({
ui: {
title: 'form.role.label',
fieldType: 'radioGroup',
fieldProps: {
options: [
{ value: 'admin', label: 'form.role.options.admin' },
{ value: 'user', label: 'form.role.options.user' },
{ value: 'guest', label: 'form.role.options.guest' },
],
},
},
}),
})
// messages/ru.json
{ "form": { "role": { "label": "Роль", "options": { "admin": "Администратор", "user": "Пользователь", "guest": "Гость" } } } }
// messages/en.json
{ "form": { "role": { "label": "Role", "options": { "admin": "Administrator", "user": "User", "guest": "Guest" } } } }
Подход 2: динамические options
function RoleForm() {
const t = useTranslations('form.role')
const roleOptions = [
{ value: 'admin', label: t('options.admin') },
{ value: 'user', label: t('options.user') },
{ value: 'guest', label: t('options.guest') },
]
return (
<Form schema={Schema} initialValue={data} onSubmit={save}>
<Form.Field.RadioGroup name="role" options={roleOptions} />
<Form.Button.Submit />
</Form>
)
}
Подход 1 чище для автогенерации (FromSchema). Подход 2 — когда options приходят с сервера.
RTL-языки (арабский, иврит)
Chakra UI v3 нативно поддерживает RTL через
// providers.tsx
<ChakraProvider value={createSystem({
direction: locale === 'ar' ? 'rtl' : 'ltr',
})}>
Все компоненты @letar/forms автоматически:
- Зеркалят layout (label справа, ошибка слева)Переключают
Пример с тремя языками (EN/RU/AR):
const Schema = z.object({
name: z.string().min(2).meta({ ui: { title: 'form.name' } }),
email: z.string().email().meta({ ui: { title: 'form.email' } }),
city: z.string().meta({ ui: { title: 'form.city', fieldType: 'city' } }),
})
// messages/ar.json
{ "form": { "name": "الاسم", "email": "البريد الإلكتروني", "city": "المدينة" } }
При переключении на арабский: весь layout зеркалится, текст идёт справа налево, Calendar показывает арабские цифры.
Итоги
| Что | Как |
|---|---|
| Label, placeholder | i18n ключи в |
| Ошибки валидации | |
| Подключение | next-intl или любая i18n-библиотека |
| Переключение | Мгновенное, без перезагрузки формы |
Принцип: Zod-схема содержит ключи, не тексты. Тексты живут в файлах переводов.
Ссылки
- Документация: forms.letar.bestПримеры: forms-example.letar.bestGitHub: github.com/letar/formsnpm MCP:
Это десятая статья из цикла «@letar/forms — от боли к декларативным формам». Предыдущая: Offline-first формы | Следующая: MCP.
Как вы локализуете формы?