Skip to Content
Core ConceptsForms & Validation

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-form
  • defaultValues — always provide defaults to avoid uncontrolled-to-controlled warnings
  • control — passed to FormField components to connect inputs to form state
  • handleSubmit — validates before calling your submit handler
  • formState.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 value and onChange to form state
  • Displaying the translated error message when validation fails
  • Showing a required indicator when required is set
  • Rendering the label text 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 hook

Keep schemas in their own files. They are reusable across forms, API validation, and tests. The inferred TypeScript type (z.infer&lt;typeof schema&gt;) serves as the single source of truth for the form’s data shape.

Last updated on