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

Назад к блогу

reactформыi18nлокализация

i18n в React-формах: русские падежи, RTL и 120 переводов

i18n ключи в Zod .meta() + глобальный z.config({ customError }) дают полную локализацию форм без изменения компонентов. Русские plural forms через ICU Message Format.

18 мая 2026 г.

--TL;DR:

    i18n ключи в Zod
    + глобальный
    дают полную локализацию форм без изменения компонентовРусские plural forms (символ/символа/символов) работают через ICU Message Format в next-intlRTL-языки (арабский, иврит) поддерживаются нативно через Chakra UI v3 direction — все поля автоматически зеркалятся

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

    Junior: понять как переводить label, placeholder и ошибки валидации через i18n ключи в
    Middle: освоить глобальный error map для Zod, plural forms (ICU) и перевод options в Select/RadioGroupSenior: оценить архитектуру RTL-поддержки, мгновенного переключения языка и масштабирование на 120+ строк переводов

Десятая статья из цикла «@letar/forms — от боли к декларативным формам». Как перевести label, placeholder, ошибки валидации и подсказки — сохранив Zod-схему как единый источник правды.


Проблема: переведи всё

Мультиязычное приложение. Форма профиля — 10 полей. Для каждого нужно перевести:

    LabelPlaceholderHelper text (подсказка)Ошибки валидации (5-10 разных сообщений на поле)

10 полей × 4 текста × 3 языка = 120 строк переводов. И это одна форма.


Подход: i18n ключи в

Мы уже показали, как

хранит label и placeholder (подробнее — в статье 2: Zod .meta()). Для 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"
    }
  }
}

Библиотека автоматически определяет, что значение в

— это i18n ключ (содержит точку), и прогоняет через
.


Перевод ошибок валидации

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 перерендериваются из
    через новые переводыОшибки валидации — через глобальный error mapКнопки и подписи — через

Plural forms: «1 символ, 2 символа, 5 символов»

Русский язык — один из самых сложных для плюрализации. Три формы вместо двух:

ЧислоАнглийскийРусский
11 character1 символ
22 characters2 символа
55 characters5 символов
2121 characters21 символ

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 справа, ошибка слева)Переключают
    инпутовИнвертируют
    и
    Меняют направление Steps навигации

Пример с тремя языками (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, placeholderi18n ключи в
Ошибки валидации
+ переводы
Подключениеnext-intl или любая i18n-библиотека
ПереключениеМгновенное, без перезагрузки формы

Принцип: Zod-схема содержит ключи, не тексты. Тексты живут в файлах переводов.


Ссылки


Это десятая статья из цикла «@letar/forms — от боли к декларативным формам». Предыдущая: Offline-first формы | Следующая: MCP.


Как вы локализуете формы?

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

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

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