Введение: формы — самый недооценённый вызов в мобильной разработке
Если вы когда-нибудь собирали формы в мобильном приложении, вы знаете это чувство. Тут нет привычного тега <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 на сервере, так что правила точно не разойдутся.