# Forms Reference React Hook Form + Zod validation for React Native / Expo. ## Setup ```bash npx expo install react-hook-form zod @hookform/resolvers ``` ## Basic Form ```tsx import { useForm, Controller } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; const schema = z.object({ email: z.string().email("Invalid email"), password: z.string().min(8, "Min 8 characters"), }); type FormData = z.infer; export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) { const { control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(schema), defaultValues: { email: "", password: "" }, }); return ( {/* Controller pattern — repeat for each field */} ( )} /> {errors.email && {errors.email.message}} {/* Same Controller pattern for password, with secureTextEntry */} {isSubmitting ? "Submitting..." : "Login"} ); } ``` ## Zod Schema Patterns ```tsx import { z } from "zod"; // Registration form const registerSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters").max(50), email: z.string().email("Invalid email address"), password: z.string() .min(8, "At least 8 characters") .regex(/[A-Z]/, "Must contain uppercase letter") .regex(/[0-9]/, "Must contain a number"), confirmPassword: z.string(), age: z.number({ invalid_type_error: "Age must be a number" }).int().min(18, "Must be 18+").optional(), role: z.enum(["admin", "user", "guest"]), agreedToTerms: z.literal(true, { errorMap: () => ({ message: "Must agree to terms" }) }), }).refine((data) => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], }); // All-optional schema — use .optional() or .partial() const profileSchema = registerSchema.pick({ name: true, email: true }).partial(); // Nested objects — compose schemas with z.array() and references const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), country: z.string().length(2) }); const orderSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).min(1), shippingAddress: addressSchema }); ``` ## Form State ```tsx const { control, handleSubmit, watch, setValue, getValues, reset, setError, clearErrors, formState: { errors, isSubmitting, isValid, isDirty, // Any field changed from defaultValues dirtyFields, // Which fields changed touchedFields, // Which fields were focused }, } = useForm({ resolver: zodResolver(schema) }); // Watch a field value const password = watch("password"); const allValues = watch(); // Watch all // Set a value programmatically setValue("email", "prefilled@example.com", { shouldValidate: true }); // Reset form reset(); // Back to defaultValues reset({ email: "new@email.com" }); // Reset with new values // Set server-side errors setError("email", { message: "Email already in use" }); ``` ## Async Submit with Error Handling ```tsx const { handleSubmit, setError } = useForm(); const onSubmit = async (data: FormData) => { try { await api.post("/auth/register", data); router.replace("/home"); } catch (error) { if (error instanceof ApiError && error.status === 409) { setError("email", { message: "Email already registered" }); } else { setError("root", { message: "Something went wrong. Please try again." }); } } }; // Display root error {errors.root && {errors.root.message}} ``` ## Multi-Step Forms ```tsx const schema = z.object({ step1: z.object({ name: z.string().min(1), email: z.string().email() }), step2: z.object({ phone: z.string(), address: z.string() }), step3: z.object({ password: z.string().min(8), confirmPassword: z.string() }), }); type FormData = z.infer; export function MultiStepForm() { const [step, setStep] = useState(1); const { control, handleSubmit, trigger, formState: { errors } } = useForm({ resolver: zodResolver(schema), }); const nextStep = async () => { const stepKey = `step${step}` as keyof FormData; const valid = await trigger(stepKey); // Validate only current step's fields if (valid) setStep(s => s + 1); }; // Render step component by index, with Back/Next/Submit navigation // Key pattern: trigger(stepKey) validates only current step before advancing return (/* StepOne | StepTwo | StepThree + Back/Next/Submit buttons */); } ``` ## Reusable Field Components ```tsx // components/ui/FormField.tsx import { Controller, Control, FieldValues, Path } from "react-hook-form"; interface FormFieldProps { control: Control; name: Path; label: string; placeholder?: string; secureTextEntry?: boolean; keyboardType?: TextInputProps["keyboardType"]; } export function FormField({ control, name, label, placeholder, secureTextEntry, keyboardType, }: FormFieldProps) { // Wraps Controller with: label, styled TextInput, and error message display // Uses fieldState.error for per-field error, accessibilityLabel for a11y return ( ( {label} {error && {error.message}} )} /> ); } // Usage ``` ## Dynamic Arrays ```tsx import { useFieldArray } from "react-hook-form"; const schema = z.object({ tags: z.array(z.object({ value: z.string().min(1) })).min(1, "Add at least one tag"), }); function TagsForm() { const { control, handleSubmit } = useForm>(); const { fields, append, remove } = useFieldArray({ control, name: "tags" }); return ( {fields.map((field, index) => ( ( )} /> remove(index)}> ))} append({ value: "" })}>+ Add Tag ); } ``` ## Keyboard Handling ```tsx import { KeyboardAvoidingView, Platform, ScrollView } from "react-native"; export function FormScreen() { return ( ); } ``` ## Testing Forms ```tsx import { render, fireEvent, waitFor, screen } from "@testing-library/react-native"; import { userEvent } from "@testing-library/react-native"; it("validates required fields", async () => { render(); fireEvent.press(screen.getByText("Login")); // Submit without filling await waitFor(() => { expect(screen.getByText("Invalid email")).toBeTruthy(); expect(screen.getByText("Min 8 characters")).toBeTruthy(); }); }); it("submits with valid data", async () => { const onSubmit = jest.fn(); const user = userEvent.setup(); render(); await user.type(screen.getByPlaceholderText("Email"), "user@example.com"); await user.type(screen.getByPlaceholderText("Password"), "password123"); await user.press(screen.getByText("Login")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123" }); }); }); ```