Формы в React Native 2026: React Hook Form, Zod и клавиатура

Практический гайд по формам в React Native: настраиваем React Hook Form 7.71 с Zod-валидацией, решаем проблему клавиатуры через react-native-keyboard-controller 1.20 и собираем многошаговые формы с примерами кода.

Введение: формы — самый недооценённый вызов в мобильной разработке

Если вы когда-нибудь собирали формы в мобильном приложении, вы знаете это чувство. Тут нет привычного тега <form>, автокомплит ведёт себя странно, клавиатура перекрывает полэкрана, а пользователь тыкает одним пальцем по крошечным полям. Каждое лишнее поле буквально режет конверсию. И всё это, конечно, должно одинаково работать на iOS и Android.

Ну, хорошая новость — в 2026 году экосистема React Native наконец-то дозрела. React Hook Form 7.71 минимизирует перерендеры и даёт типобезопасный API. Zod закрывает вопрос валидации с автоматическим выведением TypeScript-типов. А react-native-keyboard-controller 1.20 решает ту самую извечную боль с клавиатурой — причём эта библиотека теперь официально рекомендована командой Reanimated взамен устаревшего useAnimatedKeyboard.

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

Установка и настройка стека

Необходимые пакеты

Стек состоит из трёх основных библиотек плюс один связующий пакет:

# Для Expo-проектов
npx expo install react-native-keyboard-controller
npm install react-hook-form zod @hookform/resolvers

# Для bare React Native
npm install react-hook-form zod @hookform/resolvers react-native-keyboard-controller
cd ios && pod install && cd ..

Актуальные версии на март 2026:

  • react-hook-form — 7.71.x
  • zod — 3.24.x
  • @hookform/resolvers — 5.x
  • react-native-keyboard-controller — 1.20.x

Настройка KeyboardProvider

Библиотека react-native-keyboard-controller требует обёртки KeyboardProvider на верхнем уровне приложения. Без неё дочерние компоненты просто не увидят состояние клавиатуры:

// app/_layout.tsx (Expo Router) или App.tsx
import { KeyboardProvider } from 'react-native-keyboard-controller';

export default function RootLayout() {
  return (
    <KeyboardProvider>
      {/* Остальные провайдеры и навигация */}
    </KeyboardProvider>
  );
}

Кстати, в Expo SDK 55 библиотека уже включена в Expo Go — так что для первых экспериментов даже не понадобится кастомный dev-клиент. Приятный бонус.

Основы React Hook Form в React Native

Почему именно React Hook Form

В React Native нет HTML-элементов формы, и библиотеки вроде Formik, которые завязаны на DOM-события, требуют дополнительных обёрток. React Hook Form изначально дружит с React Native через компонент Controller и хук useController.

Что это даёт на практике:

  • Минимум перерендеров — неуправляемые компоненты под капотом, изолированная подписка на состояние через useFormState
  • Размер бандла ~9 КБ — примерно вдвое меньше Formik
  • Строгая типизация — полная поддержка TypeScript, включая типы ошибок и watch
  • Гибкая валидация — встроенные правила или внешние схемы через resolver

Компонент Controller — мост к TextInput

Поскольку TextInput в React Native — управляемый компонент, для интеграции с React Hook Form нам нужен Controller. Он оборачивает инпут и связывает его с формой:

import { View, Text, TextInput, StyleSheet } from 'react-native';
import { useForm, Controller } from 'react-hook-form';

type LoginForm = {
  email: string;
  password: string;
};

export default function LoginScreen() {
  const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit = (data: LoginForm) => {
    console.log('Данные формы:', data);
  };

  return (
    <View style={styles.container}>
      <Controller
        control={control}
        name="email"
        rules={{ required: 'Введите email' }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            style={[styles.input, errors.email && styles.inputError]}
            placeholder="Email"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            keyboardType="email-address"
            autoCapitalize="none"
            autoCorrect={false}
          />
        )}
      />
      {errors.email && (
        <Text style={styles.errorText}>{errors.email.message}</Text>
      )}

      <Controller
        control={control}
        name="password"
        rules={{ required: 'Введите пароль', minLength: { value: 6, message: 'Минимум 6 символов' } }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            style={[styles.input, errors.password && styles.inputError]}
            placeholder="Пароль"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
            secureTextEntry
          />
        )}
      />
      {errors.password && (
        <Text style={styles.errorText}>{errors.password.message}</Text>
      )}
    </View>
  );
}

Хук useController — для переиспользуемых инпутов

Если вы строите дизайн-систему с кастомными полями ввода, useController будет удобнее Controller. Он позволяет инкапсулировать всю логику прямо внутри компонента:

import { TextInput, Text, View, TextInputProps } from 'react-native';
import { useController, UseControllerProps, FieldValues, Path } from 'react-hook-form';

type FormInputProps<T extends FieldValues> = UseControllerProps<T> & {
  label: string;
  placeholder?: string;
  secureTextEntry?: boolean;
  keyboardType?: TextInputProps['keyboardType'];
};

export function FormInput<T extends FieldValues>({
  label,
  placeholder,
  secureTextEntry,
  keyboardType,
  ...controllerProps
}: FormInputProps<T>) {
  const {
    field: { onChange, onBlur, value },
    fieldState: { error },
  } = useController(controllerProps);

  return (
    <View style={{ marginBottom: 16 }}>
      <Text style={{ fontSize: 14, fontWeight: '600', marginBottom: 4 }}>
        {label}
      </Text>
      <TextInput
        placeholder={placeholder}
        onBlur={onBlur}
        onChangeText={onChange}
        value={value}
        secureTextEntry={secureTextEntry}
        keyboardType={keyboardType}
        autoCapitalize="none"
        style={{
          borderWidth: 1,
          borderColor: error ? '#EF4444' : '#D1D5DB',
          borderRadius: 8,
          padding: 12,
          fontSize: 16,
        }}
      />
      {error && (
        <Text style={{ color: '#EF4444', fontSize: 12, marginTop: 4 }}>
          {error.message}
        </Text>
      )}
    </View>
  );
}

И теперь любое поле формы описывается буквально одной строкой:

<FormInput
  control={control}
  name="email"
  label="Email"
  placeholder="[email protected]"
  keyboardType="email-address"
/>

Честно говоря, после того как я начал использовать такие типизированные обёртки, возвращаться к сырым Controller-ам совсем не хочется.

Валидация с помощью Zod: типобезопасная схема

Зачем Zod вместо встроенных правил

Встроенная валидация React Hook Form через rules отлично работает для простых случаев. Но стоит появиться кросс-полям (пароль и подтверждение), условной логике или желанию переиспользовать правила на сервере — и вам нужна схема.

Zod даёт три ключевых преимущества:

  • Автовыведение типовz.infer<typeof schema> генерирует TypeScript-тип автоматически
  • Композиция — схемы можно объединять, расширять и пересекать
  • Единая валидация — одну и ту же схему используем на клиенте и сервере

Определение схемы

import { z } from 'zod';

export const registrationSchema = z
  .object({
    name: z
      .string()
      .min(2, 'Имя должно содержать минимум 2 символа')
      .max(50, 'Имя не должно превышать 50 символов'),
    email: z
      .string()
      .email('Введите корректный email'),
    phone: z
      .string()
      .regex(/^\+?[1-9]\d{10,14}$/, 'Введите корректный номер телефона')
      .optional(),
    password: z
      .string()
      .min(8, 'Пароль должен содержать минимум 8 символов')
      .regex(/[A-Z]/, 'Пароль должен содержать заглавную букву')
      .regex(/[0-9]/, 'Пароль должен содержать цифру'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Пароли не совпадают',
    path: ['confirmPassword'],
  });

// TypeScript-тип генерируется автоматически
export type RegistrationData = z.infer<typeof registrationSchema>;
// Результат:
// {
//   name: string;
//   email: string;
//   phone?: string | undefined;
//   password: string;
//   confirmPassword: string;
// }

Подключение Zod к React Hook Form

Связующее звено — пакет @hookform/resolvers. Он берёт ошибки Zod и преобразует их в формат, понятный React Hook Form:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, RegistrationData } from './schema';

export default function RegistrationScreen() {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      name: '',
      email: '',
      phone: '',
      password: '',
      confirmPassword: '',
    },
    mode: 'onBlur', // Валидация при потере фокуса — оптимально для мобилок
  });

  const onSubmit = async (data: RegistrationData) => {
    // data полностью типизирована и провалидирована
    await registerUser(data);
  };

  return (
    // ... форма с Controller или FormInput
  );
}

Обратите внимание на mode: 'onBlur'. На мобильных устройствах валидация при каждом нажатии клавиши (onChange) ужасно раздражает — пользователь ещё даже не закончил набирать email, а ему уже прилетает ошибка. Валидация при потере фокуса — это та самая золотая середина: обратная связь достаточно быстрая, но без лишней назойливости.

Управление клавиатурой: react-native-keyboard-controller

Проблема, которую мы решаем

Давайте честно: встроенный KeyboardAvoidingView — это полумера. На iOS он работает с behavior="padding", на Android — без него. Вложенные скроллы ломают всё. А если форма длинная и нужное поле ввода находится где-то внизу экрана, стандартное решение просто не прокрутит к нему.

Библиотека react-native-keyboard-controller версии 1.20 решает эти проблемы фундаментально — она работает на нативном уровне и обеспечивает плавные анимации на обеих платформах. Её используют Bluesky, Expensify и другие крупные приложения, так что решение проверено боем.

KeyboardAwareScrollView — замена KeyboardAvoidingView

Главный компонент для форм — KeyboardAwareScrollView. Он автоматически прокручивает к активному полю при появлении клавиатуры:

import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';

export default function RegistrationForm() {
  return (
    <KeyboardAwareScrollView
      bottomOffset={20}
      style={{ flex: 1 }}
      contentContainerStyle={{ padding: 16 }}
    >
      {/* Поля формы */}
      <FormInput control={control} name="name" label="Имя" />
      <FormInput control={control} name="email" label="Email" keyboardType="email-address" />
      <FormInput control={control} name="phone" label="Телефон" keyboardType="phone-pad" />
      <FormInput control={control} name="password" label="Пароль" secureTextEntry />
      <FormInput control={control} name="confirmPassword" label="Подтверждение пароля" secureTextEntry />
    </KeyboardAwareScrollView>
  );
}

Параметр bottomOffset задаёт дополнительный отступ снизу — чтобы активное поле не упиралось прямо в клавиатуру.

KeyboardToolbar — навигация между полями

На iOS пользователи привыкли к кнопкам «Предыдущее» и «Следующее» над клавиатурой. KeyboardToolbar добавляет эту штуку без единой строки нативного кода:

import {
  KeyboardAwareScrollView,
  KeyboardToolbar,
} from 'react-native-keyboard-controller';
import { View } from 'react-native';

export default function FormWithToolbar() {
  return (
    <View style={{ flex: 1 }}>
      <KeyboardAwareScrollView
        style={{ flex: 1 }}
        contentContainerStyle={{ padding: 16 }}
      >
        <FormInput control={control} name="name" label="Имя" />
        <FormInput control={control} name="email" label="Email" />
        <FormInput control={control} name="password" label="Пароль" secureTextEntry />
      </KeyboardAwareScrollView>

      <KeyboardToolbar />
    </View>
  );
}

Тулбар сам находит все TextInput внутри KeyboardAwareScrollView и даёт навигацию между ними. Кнопка «Готово» скрывает клавиатуру. Всё просто.

KeyboardStickyView — липкая кнопка отправки

Частый паттерн в мобильных формах — кнопка «Зарегистрироваться», которая прилипает к верхнему краю клавиатуры. Для этого есть KeyboardStickyView:

import { KeyboardStickyView } from 'react-native-keyboard-controller';
import { Pressable, Text, StyleSheet } from 'react-native';

<KeyboardStickyView offset={{ opened: 0, closed: 0 }}>
  <Pressable
    style={styles.submitButton}
    onPress={handleSubmit(onSubmit)}
  >
    <Text style={styles.submitText}>Зарегистрироваться</Text>
  </Pressable>
</KeyboardStickyView>

Полный пример: форма регистрации

Итак, давайте соберём все кусочки вместе. Вот готовая форма регистрации, которую можно взять за основу для своего проекта:

// schema.ts
import { z } from 'zod';

export const registrationSchema = z
  .object({
    name: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
    email: z.string().email('Некорректный email'),
    password: z
      .string()
      .min(8, 'Минимум 8 символов')
      .regex(/[A-Z]/, 'Нужна заглавная буква')
      .regex(/[0-9]/, 'Нужна цифра'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Пароли не совпадают',
    path: ['confirmPassword'],
  });

export type RegistrationData = z.infer<typeof registrationSchema>;
// RegistrationScreen.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet, Alert } from 'react-native';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  KeyboardAwareScrollView,
  KeyboardToolbar,
  KeyboardStickyView,
} from 'react-native-keyboard-controller';
import { FormInput } from './FormInput';
import { registrationSchema, RegistrationData } from './schema';

export default function RegistrationScreen() {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting, isValid },
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
    },
    mode: 'onBlur',
  });

  const onSubmit = async (data: RegistrationData) => {
    try {
      // Отправка данных на сервер
      const response = await fetch('https://api.example.com/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: data.name,
          email: data.email,
          password: data.password,
        }),
      });

      if (!response.ok) throw new Error('Ошибка регистрации');
      Alert.alert('Успех', 'Аккаунт создан!');
    } catch (error) {
      Alert.alert('Ошибка', 'Не удалось создать аккаунт');
    }
  };

  return (
    <View style={styles.container}>
      <KeyboardAwareScrollView
        bottomOffset={80}
        style={styles.scroll}
        contentContainerStyle={styles.content}
      >
        <Text style={styles.title}>Создать аккаунт</Text>
        <Text style={styles.subtitle}>Заполните данные для регистрации</Text>

        <FormInput
          control={control}
          name="name"
          label="Имя"
          placeholder="Иван Петров"
        />
        <FormInput
          control={control}
          name="email"
          label="Email"
          placeholder="[email protected]"
          keyboardType="email-address"
        />
        <FormInput
          control={control}
          name="password"
          label="Пароль"
          placeholder="Минимум 8 символов"
          secureTextEntry
        />
        <FormInput
          control={control}
          name="confirmPassword"
          label="Подтверждение пароля"
          placeholder="Повторите пароль"
          secureTextEntry
        />
      </KeyboardAwareScrollView>

      <KeyboardStickyView offset={{ opened: 0, closed: 34 }}>
        <View style={styles.buttonContainer}>
          <Pressable
            style={[styles.button, isSubmitting && styles.buttonDisabled]}
            onPress={handleSubmit(onSubmit)}
            disabled={isSubmitting}
          >
            <Text style={styles.buttonText}>
              {isSubmitting ? 'Регистрация...' : 'Зарегистрироваться'}
            </Text>
          </Pressable>
        </View>
      </KeyboardStickyView>

      <KeyboardToolbar />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#FFFFFF' },
  scroll: { flex: 1 },
  content: { padding: 24, paddingBottom: 100 },
  title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
  subtitle: { fontSize: 16, color: '#6B7280', marginBottom: 32 },
  buttonContainer: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: '#FFFFFF' },
  button: {
    backgroundColor: '#6C63FF',
    paddingVertical: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

Многошаговые формы: разбиваем на этапы

Почему многошаговые формы работают лучше

Длинная форма на одном экране — убийца конверсии на мобильных. Пользователь видит 10 полей, внутренне паникует и просто закрывает приложение. Знакомо?

Разбивая форму на шаги по 2–3 поля, вы создаёте ощущение прогресса и снижаете когнитивную нагрузку. По моему опыту, конверсия многошаговых форм стабильно выше — иногда значительно.

Реализация с React Hook Form

Ключевая идея — использовать один экземпляр useForm на все шаги, а валидировать поля только текущего шага через trigger:

import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, RegistrationData } from './schema';
import { FormInput } from './FormInput';

const STEPS = [
  { fields: ['name', 'email'] as const, title: 'Основная информация' },
  { fields: ['password', 'confirmPassword'] as const, title: 'Безопасность' },
];

export default function MultiStepForm() {
  const [step, setStep] = useState(0);
  const currentStep = STEPS[step];

  const { control, handleSubmit, trigger, formState: { errors } } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: { name: '', email: '', password: '', confirmPassword: '' },
    mode: 'onBlur',
  });

  const goToNextStep = async () => {
    // Валидируем только поля текущего шага
    const isStepValid = await trigger(currentStep.fields as any);
    if (isStepValid && step < STEPS.length - 1) {
      setStep(step + 1);
    }
  };

  const goToPrevStep = () => {
    if (step > 0) setStep(step - 1);
  };

  const onSubmit = async (data: RegistrationData) => {
    console.log('Все данные:', data);
  };

  const isLastStep = step === STEPS.length - 1;

  return (
    <View style={styles.container}>
      {/* Индикатор прогресса */}
      <View style={styles.progressBar}>
        {STEPS.map((_, index) => (
          <View
            key={index}
            style={[
              styles.progressDot,
              index <= step && styles.progressDotActive,
            ]}
          />
        ))}
      </View>

      <Text style={styles.stepTitle}>{currentStep.title}</Text>

      {/* Отображаем поля текущего шага */}
      {currentStep.fields.map((fieldName) => (
        <FormInput
          key={fieldName}
          control={control}
          name={fieldName}
          label={fieldName === 'name' ? 'Имя' : fieldName === 'email' ? 'Email' : fieldName === 'password' ? 'Пароль' : 'Подтверждение'}
          secureTextEntry={fieldName.includes('password') || fieldName.includes('Password')}
          keyboardType={fieldName === 'email' ? 'email-address' : 'default'}
        />
      ))}

      {/* Навигация */}
      <View style={styles.navigation}>
        {step > 0 && (
          <Pressable style={styles.backButton} onPress={goToPrevStep}>
            <Text style={styles.backButtonText}>Назад</Text>
          </Pressable>
        )}
        <Pressable
          style={styles.nextButton}
          onPress={isLastStep ? handleSubmit(onSubmit) : goToNextStep}
        >
          <Text style={styles.nextButtonText}>
            {isLastStep ? 'Отправить' : 'Далее'}
          </Text>
        </Pressable>
      </View>
    </View>
  );
}

Продвинутые паттерны

Динамические массивы полей с useFieldArray

Бывает, нужно дать пользователю возможность добавлять произвольное количество элементов — например, навыки в профиле или адреса доставки. Для этого есть хук useFieldArray:

import { useForm, useFieldArray } from 'react-hook-form';
import { View, Pressable, Text } from 'react-native';
import { z } from 'zod';

const profileSchema = z.object({
  skills: z
    .array(z.object({ value: z.string().min(1, 'Укажите навык') }))
    .min(1, 'Добавьте хотя бы один навык')
    .max(10, 'Максимум 10 навыков'),
});

type ProfileData = z.infer<typeof profileSchema>;

export default function SkillsForm() {
  const { control, handleSubmit } = useForm<ProfileData>({
    resolver: zodResolver(profileSchema),
    defaultValues: { skills: [{ value: '' }] },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'skills',
  });

  return (
    <View>
      {fields.map((field, index) => (
        <View key={field.id} style={{ flexDirection: 'row', alignItems: 'center' }}>
          <FormInput
            control={control}
            name={`skills.${index}.value`}
            label={`Навык ${index + 1}`}
          />
          {fields.length > 1 && (
            <Pressable onPress={() => remove(index)}>
              <Text style={{ color: '#EF4444', fontSize: 20 }}>✕</Text>
            </Pressable>
          )}
        </View>
      ))}
      {fields.length < 10 && (
        <Pressable onPress={() => append({ value: '' })}>
          <Text style={{ color: '#6C63FF' }}>+ Добавить навык</Text>
        </Pressable>
      )}
    </View>
  );
}

Асинхронная валидация: проверка занятости email

Zod поддерживает асинхронные проверки через .refine. Вот как можно проверить, не занят ли email:

const registrationSchemaAsync = z.object({
  email: z
    .string()
    .email('Некорректный email')
    .refine(
      async (email) => {
        const response = await fetch(
          `https://api.example.com/check-email?email=${encodeURIComponent(email)}`
        );
        const { available } = await response.json();
        return available;
      },
      { message: 'Этот email уже зарегистрирован' }
    ),
  // ... другие поля
});

Но тут есть нюанс: будьте аккуратны с асинхронной валидацией в mode: 'onBlur' — каждый раз при потере фокуса будет улетать запрос на сервер. Лучше добавить дебаунс или вынести эту проверку на момент отправки формы.

Маскированный ввод: телефон и карты

Для форматированного ввода номера телефона или банковской карты хорошо подходит библиотека react-native-mask-input в связке с Controller:

import MaskInput from 'react-native-mask-input';
import { Controller } from 'react-hook-form';

<Controller
  control={control}
  name="phone"
  render={({ field: { onChange, value } }) => (
    <MaskInput
      value={value}
      onChangeText={(masked, unmasked) => {
        onChange(unmasked); // Сохраняем чистый номер без маски
      }}
      mask={['+7 (', /\d/, /\d/, /\d/, ') ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, '-', /\d/, /\d/]}
      placeholder="+7 (999) 123-45-67"
      keyboardType="phone-pad"
      style={styles.input}
    />
  )}
/>

Оптимизация производительности форм

Изоляция перерендеров с useFormState

Когда полей в форме становится много, перерендер всей формы при изменении одного поля начинает ощутимо тормозить. Хук useFormState позволяет подписаться только на конкретные части состояния:

import { useFormState } from 'react-hook-form';

function SubmitButton({ control }: { control: any }) {
  // Этот компонент перерендерится только при изменении isSubmitting или isValid
  const { isSubmitting, isValid } = useFormState({ control });

  return (
    <Pressable
      disabled={isSubmitting || !isValid}
      style={[styles.button, (!isValid || isSubmitting) && styles.buttonDisabled]}
    >
      <Text style={styles.buttonText}>
        {isSubmitting ? 'Отправка...' : 'Отправить'}
      </Text>
    </Pressable>
  );
}

Правильные keyboardType для каждого поля

Подбор правильного типа клавиатуры — это мелочь, которая серьёзно влияет на UX. Правильная клавиатура снижает количество ошибок ввода:

  • email-address — для email (показывает @ и . на основном экране)
  • phone-pad — для номеров телефонов
  • numeric — для числовых значений (возраст, индекс)
  • decimal-pad — для чисел с точкой (цена, вес)
  • url — для URL-адресов

returnKeyType для навигации между полями

Свойство returnKeyType определяет надпись на кнопке Enter. Используйте "next" для промежуточных полей и "done" или "send" для последнего:

<TextInput
  returnKeyType={isLastField ? 'done' : 'next'}
  onSubmitEditing={() => {
    if (isLastField) {
      handleSubmit(onSubmit)();
    } else {
      nextInputRef.current?.focus();
    }
  }}
  blurOnSubmit={false} // Не скрывать клавиатуру при переходе
/>

Обработка ошибок и UX

Стратегии показа ошибок

Есть несколько подходов к отображению ошибок, и у каждого свои плюсы:

  • onBlur (рекомендуется) — ошибка появляется после того, как пользователь покинул поле. Не раздражает, но даёт обратную связь вовремя
  • onSubmit — все ошибки показываются разом при попытке отправки. Хорошо подходит для коротких форм
  • onTouched — похоже на onBlur, но ошибка сбрасывается при повторном редактировании. Удобно для итеративного исправления
  • onChange — ошибка обновляется при каждом нажатии клавиши. Используйте только для полей с маской или специфическим форматом

Серверные ошибки

Серверные ошибки (типа «email уже занят») устанавливаются через setError:

const { setError } = useForm<RegistrationData>(/* ... */);

const onSubmit = async (data: RegistrationData) => {
  try {
    await registerUser(data);
  } catch (error: any) {
    if (error.code === 'EMAIL_TAKEN') {
      setError('email', {
        type: 'server',
        message: 'Этот email уже зарегистрирован',
      });
    } else {
      setError('root', {
        type: 'server',
        message: 'Произошла ошибка. Попробуйте позже.',
      });
    }
  }
};

// Отображение корневой ошибки
{errors.root && (
  <View style={styles.errorBanner}>
    <Text style={styles.errorBannerText}>{errors.root.message}</Text>
  </View>
)}

Часто задаваемые вопросы (FAQ)

Можно ли использовать Formik вместо React Hook Form в React Native?

Можно, Formik по-прежнему работает. Но React Hook Form предпочтительнее по нескольким причинам: он легче (~9 КБ против ~13 КБ у Formik), вызывает значительно меньше перерендеров и лучше дружит с TypeScript. Formik имеет смысл оставить, если проект уже на нём и миграция не стоит потраченного времени.

Чем Zod лучше Yup для валидации форм?

Zod изначально создавался с приоритетом на TypeScript — он автоматически выводит типы из схемы через z.infer, и вам не приходится дублировать типы вручную. Yup тоже поддерживает TypeScript, но его типизация была добавлена позже и она менее строгая. Если проект на TypeScript (а в 2026-м это, пожалуй, практически все проекты), Zod — более естественный выбор.

Как правильно обрабатывать клавиатуру в React Native формах?

Встроенный KeyboardAvoidingView работает нестабильно и по-разному ведёт себя на iOS и Android. Рекомендуемое решение — react-native-keyboard-controller, который с версии 1.20 стал фактическим стандартом. Используйте KeyboardAwareScrollView для автопрокрутки к активному полю и KeyboardToolbar для навигации между полями.

Как реализовать многошаговую форму в React Native?

Используйте один экземпляр useForm на все шаги, а метод trigger — для валидации только полей текущего шага. Храните номер текущего шага в useState и рендерите соответствующие поля. Это позволяет сохранять данные между шагами без дополнительного стейт-менеджера.

Нужна ли серверная валидация, если есть клиентская через Zod?

Обязательно нужна. Клиентская валидация — это про UX, но её легко обойти. Серверная валидация — единственная надёжная защита ваших данных. Хорошая новость: Zod позволяет использовать одну и ту же схему и в React Native, и в Node.js на сервере, так что правила точно не разойдутся.

Об авторе Editorial Team

Our team of expert writers and editors.