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.
en.json
{
"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, andonBlurto 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/usingimport { z } from 'zod/v4' - Validation messages are i18n keys, not raw strings
- i18n keys added to both
en.jsonandar.json -
useFormconfigured withzodResolver(schema) -
defaultValuesprovided for all fields -
FormFieldwraps each input withname,control, andlabel - 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)