Skip to Content
GuidesCreating a Form

Creating a Form

RNCopilot uses a schema-first approach to forms: define the shape with Zod, validate with i18n error messages, and render with react-hook-form’s FormField integration. This guide builds a complete product creation form.

Forms in RNCopilot use three libraries together: Zod for schema validation, react-hook-form for state management, and the built-in FormField component for rendering fields with labels, errors, and accessibility.

Define the Zod schema

Schemas live in the feature’s schemas/ directory. Validation messages use i18n keys so they are automatically translated.

// src/features/products/schemas/productSchema.ts import { z } from 'zod/v4'; export const productSchema = z.object({ name: z.string().min(1, 'validation.required').max(100, 'validation.nameTooLong'), price: z.number({ message: 'validation.required' }).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>;

Import from zod/v4, not zod. This project uses Zod v4 — importing from the wrong path will cause runtime errors.

Key points:

  • Validation messages are i18n keys (e.g., 'validation.required'), not raw strings
  • Export the inferred type (ProductFormData) for use in hooks and services
  • Use z.number({ message: '...' }) to customize the “expected number” error for non-numeric input

Add validation translation keys

Add the validation keys to both locale files. These keys are shared across all forms.

{ "validation": { "required": "This field is required", "nameTooLong": "Name must be 100 characters or fewer", "priceMin": "Price must be 0 or greater", "descriptionMax": "Description must be 500 characters or fewer", "emailInvalid": "Please enter a valid email address" }, "fields": { "productName": "Product Name", "price": "Price", "category": "Category", "description": "Description" }, "actions": { "createProduct": "Create Product" } }

Set up the form with useForm

Use useForm from react-hook-form with zodResolver to connect the schema.

// 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 { Select } from '@/common/components/Select'; import { TextArea } from '@/common/components/TextArea'; import { useCreateProduct } from '../hooks/useProducts'; import { productSchema, type ProductFormData } from '../schemas/productSchema'; const CATEGORY_OPTIONS = [ { label: 'Electronics', value: 'electronics' }, { label: 'Clothing', value: 'clothing' }, { label: 'Books', value: 'books' }, { label: 'Home & Garden', value: 'home-garden' }, ]; export function CreateProductForm({ onSuccess }: { onSuccess: () => void }) { const { t } = useTranslation(); const createProduct = useCreateProduct(); const { control, handleSubmit, formState: { isSubmitting }, reset, } = useForm<ProductFormData>({ resolver: zodResolver(productSchema), defaultValues: { name: '', price: 0, category: '', description: '', }, }); const onSubmit = async (data: ProductFormData) => { try { await createProduct.mutateAsync(data); reset(); onSuccess(); } catch { // Error is handled by the mutation's onError or a Snackbar } }; return ( <View style={styles.form}> <FormField name="name" control={control} label={t('fields.productName')} required > <Input placeholder={t('fields.productName')} /> </FormField> <FormField name="price" control={control} label={t('fields.price')} required > <Input keyboardType="numeric" placeholder="0.00" /> </FormField> <FormField name="category" control={control} label={t('fields.category')} required > <Select options={CATEGORY_OPTIONS} placeholder={t('fields.category')} /> </FormField> <FormField name="description" control={control} label={t('fields.description')} > <TextArea placeholder={t('fields.description')} 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, }, }));

Handle form submission errors

For mutation errors, display feedback using a Snackbar or error state:

import { useState } from 'react'; import { Snackbar } from '@/common/components/Snackbar'; export function CreateProductForm({ onSuccess }: { onSuccess: () => void }) { const { t } = useTranslation(); const createProduct = useCreateProduct(); const [errorMessage, setErrorMessage] = useState<string | null>(null); // ... useForm setup ... const onSubmit = async (data: ProductFormData) => { try { await createProduct.mutateAsync(data); reset(); onSuccess(); } catch (error) { setErrorMessage( error instanceof Error ? error.message : t('errors.generic') ); } }; return ( <View style={styles.form}> {/* ... form fields ... */} <Snackbar visible={!!errorMessage} message={errorMessage ?? ''} variant="error" onDismiss={() => setErrorMessage(null)} /> </View> ); }

Use the form in a screen

// app/(main)/create-product.tsx import { router } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { ScreenContainer, Text } from '@/common/components'; import { CreateProductForm } from '@/features/products/components/CreateProductForm'; export default function CreateProductScreen() { const { t } = useTranslation(); return ( <ScreenContainer scrollable padded> <Text variant="h1">{t('products.addNew')}</Text> <CreateProductForm onSuccess={() => router.back()} /> </ScreenContainer> ); }

How FormField Works

The FormField component connects react-hook-form’s control to the child input component. It handles:

  • Label rendering with optional required indicator
  • Error display with translated messages (the i18n key from your Zod schema is passed through t())
  • Accessibility — associates label and error with the input via accessibility props
  • Value binding — passes value, onChange, and onBlur to the child
FormField ├── Label text + required asterisk ├── Child input (Input, Select, TextArea, etc.) └── Error message (translated from Zod validation key)

Checklist

  • Schema defined in src/features/<name>/schemas/ using import { z } from 'zod/v4'
  • Validation messages are i18n keys, not raw strings
  • i18n keys added to both en.json and ar.json
  • useForm configured with zodResolver(schema)
  • defaultValues provided for all fields
  • FormField wraps each input with name, control, and label
  • Submit uses handleSubmit(onSubmit) — not manual validation
  • Mutation errors are displayed to the user (Snackbar or inline error)
  • Loading state shown during submission (isSubmitting || mutation.isPending)
Last updated on