Forms & Validation
RNCopilot uses a schema-first approach to forms: define your validation schema with Zod, wire it up with react-hook-form, and use the FormField component to connect inputs to form state.
Schema-First with Zod
This project uses import { z } from 'zod/v4' — not 'zod'. Always import from zod/v4.
Define your schema before writing any form components. Validation messages are i18n keys, not raw strings:
// src/features/auth/schemas/loginSchema.ts
import { z } from 'zod/v4';
export const loginSchema = z.object({
email: z.email('validation.emailInvalid'),
password: z.string().min(8, 'validation.passwordMin'),
});
export type LoginFormData = z.infer<typeof loginSchema>;When rendered, error messages like 'validation.emailInvalid' are passed through t() and displayed as the translated string (e.g., “Please enter a valid email address” in English or the Arabic equivalent).
Schema Examples
// Registration schema with cross-field validation
export const registerSchema = z
.object({
username: z
.string()
.min(3, 'validation.usernameMin')
.max(20, 'validation.usernameMax')
.regex(/^[a-zA-Z0-9_]+$/, 'validation.usernameFormat'),
email: z.email('validation.emailInvalid'),
password: z.string().min(8, 'validation.passwordMin'),
confirmPassword: z.string().min(1, 'validation.required'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'validation.passwordMismatch',
path: ['confirmPassword'],
});
// Product schema
export const productSchema = z.object({
name: z.string().min(1, 'validation.required'),
price: z.number().min(0, 'validation.priceMin'),
category: z.string().min(1, 'validation.required'),
description: z.string().max(500, 'validation.descriptionMax').optional(),
});
export type ProductFormData = z.infer<typeof productSchema>;react-hook-form + zodResolver
Connect the Zod schema to react-hook-form using zodResolver:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginFormData } from '../schemas/loginSchema';
const { control, handleSubmit, formState: { isSubmitting, errors } } =
useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});Key Points
resolver: zodResolver(schema)— connects Zod validation to react-hook-formdefaultValues— always provide defaults to avoid uncontrolled-to-controlled warningscontrol— passed toFormFieldcomponents to connect inputs to form statehandleSubmit— validates before calling your submit handlerformState.isSubmitting— tracks async submission for loading indicators
FormField Component
The FormField wrapper connects an input to react-hook-form’s control and displays validation errors:
import { FormField } from '@/common/components/FormField';
import { Input } from '@/common/components/Input';
<FormField
name="email"
control={control}
label={t('fields.email')}
required
>
<Input
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</FormField>FormField handles:
- Connecting the input’s
valueandonChangeto form state - Displaying the translated error message when validation fails
- Showing a required indicator when
requiredis set - Rendering the
labeltext above the input
Full Form Example
Here is a complete form component following all project conventions:
// src/features/products/components/CreateProductForm.tsx
import { View } from 'react-native';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native-unistyles';
import { Button } from '@/common/components/Button';
import { FormField } from '@/common/components/FormField';
import { Input } from '@/common/components/Input';
import { TextArea } from '@/common/components/TextArea';
import { useCreateProduct } from '../hooks/useProducts';
import { productSchema, type ProductFormData } from '../schemas/productSchema';
interface CreateProductFormProps {
onSuccess: () => void;
}
export function CreateProductForm({ onSuccess }: CreateProductFormProps) {
const { t } = useTranslation();
const createProduct = useCreateProduct();
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
defaultValues: {
name: '',
price: 0,
category: '',
description: '',
},
});
const onSubmit = async (data: ProductFormData) => {
await createProduct.mutateAsync(data);
onSuccess();
};
return (
<View style={styles.form}>
<FormField
name="name"
control={control}
label={t('fields.productName')}
required
>
<Input autoCapitalize="words" />
</FormField>
<FormField
name="price"
control={control}
label={t('fields.price')}
required
>
<Input keyboardType="numeric" />
</FormField>
<FormField
name="category"
control={control}
label={t('fields.category')}
required
>
<Input />
</FormField>
<FormField
name="description"
control={control}
label={t('fields.description')}
>
<TextArea maxLength={500} />
</FormField>
<Button
title={t('actions.createProduct')}
onPress={handleSubmit(onSubmit)}
loading={isSubmitting || createProduct.isPending}
fullWidth
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
form: {
gap: theme.metrics.spacingV.p16,
},
}));Validation Message Flow
Zod schema
message: 'validation.emailInvalid' <-- i18n key, not raw text
|
v
react-hook-form
errors.email.message = 'validation.emailInvalid'
|
v
FormField component
t('validation.emailInvalid')
|
v
Rendered text
EN: "Please enter a valid email address"
AR: "يرجى إدخال بريد إلكتروني صحيح"File Organization
src/features/myFeature/
schemas/
myFormSchema.ts <-- Zod schema + inferred type
components/
MyForm.tsx <-- Form component using FormField
hooks/
useMyFeature.ts <-- React Query mutation hookKeep schemas in their own files. They are reusable across forms, API validation, and tests. The inferred TypeScript type (z.infer<typeof schema>) serves as the single source of truth for the form’s data shape.