Forms
Complete guide to handling forms with react-hook-form, Zod validation, and keyboard management in Caracal starter.
Forms are a fundamental part of most applications. The Caracal starter provides a robust, type-safe approach to form handling using modern React Native best practices.
Form Handling Stack
The starter uses three key libraries for forms:
- react-hook-form — Form state management
- Zod — Schema validation with TypeScript inference
- react-native-keyboard-controller — Keyboard handling
Why this combination?
- Type-safe forms with automatic TypeScript inference
- Minimal re-renders (only when needed)
- Excellent validation error handling
- Smooth keyboard interactions
- Works seamlessly with HeroUI Native components
ControlledInput Component
The starter provides ControlledInput — a wrapper around HeroUI Native's TextField that integrates with react-hook-form:
Key features:
- Uses
useControllerhook from react-hook-form - Automatically displays validation errors
- Supports all TextField props from HeroUI Native
- Type-safe with TypeScript
Import:
import { ControlledInput } from '@/components/ui';ControlledInput wraps HeroUI Native's TextField component. For direct use without forms, import TextField from heroui-native.
Basic Form Example
Let's build a simple login form step by step.
Step 1: Define Validation Schema
Use Zod to create a schema with validation rules:
import * as z from 'zod';
const schema = z.object({
email: z
.string({
required_error: 'Email is required',
})
.email('Invalid email format'),
password: z
.string({
required_error: 'Password is required',
})
.min(6, 'Password must be at least 6 characters'),
});
// Infer TypeScript type from schema
type FormType = z.infer<typeof schema>;Step 2: Create Form Component
import { zodResolver } from '@hookform/resolvers/zod';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';
import { Button } from 'heroui-native';
import { ControlledInput, View } from '@/components/ui';
import { useAuth } from '@/lib/auth';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(6, 'Password too short'),
});
type FormType = z.infer<typeof schema>;
export function LoginForm() {
const { signIn } = useAuth();
const { handleSubmit, control } = useForm<FormType>({
resolver: zodResolver(schema),
});
const onSubmit: SubmitHandler<FormType> = (data) => {
console.log(data);
signIn({ access: 'access-token', refresh: 'refresh-token' });
};
return (
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1 }}
keyboardVerticalOffset={100}
>
<View className="flex-1 justify-center gap-4 p-4">
<ControlledInput
control={control}
name="email"
label="Email"
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
testID="email-input"
/>
<ControlledInput
control={control}
name="password"
label="Password"
placeholder="Enter your password"
secureTextEntry
testID="password-input"
/>
<Button
onPress={handleSubmit(onSubmit)}
variant="solid"
color="primary"
testID="login-button"
>
Login
</Button>
</View>
</KeyboardAvoidingView>
);
}Key Points
Correct Imports:
// HeroUI Native component
import { Button } from 'heroui-native';
// Custom components
import { ControlledInput, View } from '@/components/ui';
// Keyboard handling (NOT from 'react-native')
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';Button Usage:
// Correct — HeroUI Native Button
<Button onPress={handleSubmit(onSubmit)} variant="solid">
Login
</Button>
// Wrong — old custom Button API (doesn't exist)
<Button label="Login" onPress={handleSubmit(onSubmit)} variant="primary" />Always wrap forms in KeyboardAvoidingView from react-native-keyboard-controller to prevent the keyboard from covering inputs.
Advanced Form Patterns
Form with Multiple Sections
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';
import { Button } from 'heroui-native';
import { ControlledInput, View, Text, ScrollView } from '@/components/ui';
const schema = z.object({
// Personal Info
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
email: z.string().email('Invalid email'),
// Account Info
username: z.string().min(3, 'At least 3 characters'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type FormType = z.infer<typeof schema>;
export function RegistrationForm() {
const { control, handleSubmit } = useForm<FormType>({
resolver: zodResolver(schema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
username: '',
password: '',
confirmPassword: '',
},
});
const onSubmit = (data: FormType) => {
console.log('Form data:', data);
};
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView className="flex-1">
<View className="gap-6 p-4">
<View className="gap-4">
<Text className="text-xl font-bold">Personal Information</Text>
<ControlledInput
control={control}
name="firstName"
label="First Name"
placeholder="John"
/>
<ControlledInput
control={control}
name="lastName"
label="Last Name"
placeholder="Doe"
/>
<ControlledInput
control={control}
name="email"
label="Email"
placeholder="john.doe@example.com"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
<View className="gap-4">
<Text className="text-xl font-bold">Account Information</Text>
<ControlledInput
control={control}
name="username"
label="Username"
placeholder="johndoe"
autoCapitalize="none"
/>
<ControlledInput
control={control}
name="password"
label="Password"
placeholder="Enter password"
secureTextEntry
/>
<ControlledInput
control={control}
name="confirmPassword"
label="Confirm Password"
placeholder="Re-enter password"
secureTextEntry
/>
</View>
<Button onPress={handleSubmit(onSubmit)} variant="solid">
Create Account
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}Form with Conditional Fields
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button, Switch } from 'heroui-native';
import { ControlledInput, View, Text } from '@/components/ui';
const schema = z.object({
enableNotifications: z.boolean(),
email: z.string().email().optional(),
phone: z.string().min(10).optional(),
}).refine(
(data) => {
if (data.enableNotifications) {
return data.email || data.phone;
}
return true;
},
{
message: 'Please provide either email or phone for notifications',
path: ['email'],
}
);
type FormType = z.infer<typeof schema>;
export function NotificationSettingsForm() {
const { control, handleSubmit } = useForm<FormType>({
resolver: zodResolver(schema),
defaultValues: {
enableNotifications: false,
},
});
const enableNotifications = useWatch({
control,
name: 'enableNotifications',
});
const onSubmit = (data: FormType) => {
console.log(data);
};
return (
<View className="gap-4 p-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg">Enable Notifications</Text>
<Switch
isSelected={enableNotifications}
onValueChange={(value) => {
control._formValues.enableNotifications = value;
}}
/>
</View>
{enableNotifications && (
<View className="gap-4">
<ControlledInput
control={control}
name="email"
label="Email (Optional)"
placeholder="notifications@example.com"
keyboardType="email-address"
/>
<ControlledInput
control={control}
name="phone"
label="Phone (Optional)"
placeholder="+1 234 567 8900"
keyboardType="phone-pad"
/>
</View>
)}
<Button onPress={handleSubmit(onSubmit)}>
Save Settings
</Button>
</View>
);
}Form with Loading State
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from 'heroui-native';
import { ControlledInput, View } from '@/components/ui';
const schema = z.object({
email: z.string().email(),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type FormType = z.infer<typeof schema>;
export function ContactForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const { control, handleSubmit, reset } = useForm<FormType>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormType) => {
setIsSubmitting(true);
try {
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(data),
});
reset();
alert('Message sent successfully!');
} catch (error) {
alert('Failed to send message');
} finally {
setIsSubmitting(false);
}
};
return (
<View className="gap-4 p-4">
<ControlledInput
control={control}
name="email"
label="Email"
placeholder="your@email.com"
keyboardType="email-address"
isDisabled={isSubmitting}
/>
<ControlledInput
control={control}
name="message"
label="Message"
placeholder="Your message here..."
multiline
numberOfLines={4}
isDisabled={isSubmitting}
/>
<Button
onPress={handleSubmit(onSubmit)}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Send Message
</Button>
</View>
);
}Validation Patterns
Custom Error Messages
const schema = z.object({
username: z
.string({
required_error: 'Username is required',
invalid_type_error: 'Username must be a string',
})
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be less than 20 characters')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores'
),
});Cross-Field Validation
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});Async Validation
const schema = z.object({
username: z.string().refine(
async (username) => {
const response = await fetch(`/api/check-username/${username}`);
const { available } = await response.json();
return available;
},
{
message: 'Username is already taken',
}
),
});Optional Fields with Transforms
const schema = z.object({
age: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined))
.refine((val) => !val || (val >= 18 && val <= 120), {
message: 'Age must be between 18 and 120',
}),
});Keyboard Handling
The starter includes react-native-keyboard-controller pre-configured for smooth keyboard interactions.
KeyboardAvoidingView
Always use KeyboardAvoidingView from react-native-keyboard-controller (not from react-native):
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
export function MyForm() {
return (
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1 }}
keyboardVerticalOffset={100}
>
{/* Form content */}
</KeyboardAvoidingView>
);
}KeyboardAwareScrollView
For forms with many fields:
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
export function LongForm() {
return (
<KeyboardAwareScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 16 }}
keyboardShouldPersistTaps="handled"
extraScrollHeight={20}
>
{/* Many form fields */}
</KeyboardAwareScrollView>
);
}Dismiss Keyboard on Tap
KeyboardProvider is already set up in src/app/_layout.tsx. Users can tap outside inputs to dismiss the keyboard automatically.
Manual Keyboard Control
import { useKeyboardController } from 'react-native-keyboard-controller';
export function MyForm() {
const { dismiss } = useKeyboardController();
const handleSubmit = (data: FormType) => {
dismiss(); // Dismiss keyboard before navigation
// Process form...
};
return (
<View>
<Button onPress={handleSubmit(onSubmit)}>Submit</Button>
</View>
);
}Creating Custom Controlled Components
You can create controlled wrappers for any HeroUI Native form component.
Example: ControlledSwitch
import { useController } from 'react-hook-form';
import type { Control, FieldValues, Path } from 'react-hook-form';
import { Switch } from 'heroui-native';
import type { SwitchProps } from 'heroui-native';
type ControlledSwitchProps<T extends FieldValues> = {
control: Control<T>;
name: Path<T>;
} & Omit<SwitchProps, 'isSelected' | 'onValueChange'>;
export function ControlledSwitch<T extends FieldValues>({
control,
name,
...switchProps
}: ControlledSwitchProps<T>) {
const { field } = useController({ control, name });
return (
<Switch
isSelected={field.value}
onValueChange={field.onChange}
{...switchProps}
/>
);
}Example: ControlledCheckbox
import { useController } from 'react-hook-form';
import type { Control, FieldValues, Path } from 'react-hook-form';
import { Checkbox } from 'heroui-native';
import type { CheckboxProps } from 'heroui-native';
type ControlledCheckboxProps<T extends FieldValues> = {
control: Control<T>;
name: Path<T>;
label?: string;
} & Omit<CheckboxProps, 'isSelected' | 'onChange'>;
export function ControlledCheckbox<T extends FieldValues>({
control,
name,
label,
...checkboxProps
}: ControlledCheckboxProps<T>) {
const { field } = useController({ control, name });
return (
<Checkbox
isSelected={field.value}
onChange={field.onChange}
{...checkboxProps}
>
{label}
</Checkbox>
);
}Best Practices
DO
- Use Zod for validation — type inference and runtime validation
- Wrap forms in
KeyboardAvoidingView— better UX on mobile - Add
testIDprops — essential for testing - Provide
defaultValues— prevents uncontrolled-to-controlled warnings - Use proper keyboard types —
email-address,phone-pad,numeric, etc. - Disable
autoCapitalizefor emails/usernames — better UX - Show loading states — use
isLoadingprop on Button - Reset form after successful submission — use
reset()fromuseForm - Handle errors gracefully — show user-friendly error messages
DON'T
- Don't use uncontrolled inputs — always use
ControlledInput - Don't import Button from
@/components/ui— use HeroUI Native Button - Don't use
KeyboardAvoidingViewfromreact-native— use fromreact-native-keyboard-controller - Don't skip validation — always validate both client and server-side
- Don't forget
testID— makes testing much harder - Don't ignore accessibility — use proper labels and hints
Common Issues & Solutions
Form values not updating
Cause: Using wrong prop names (HeroUI Native uses isDisabled not disabled)
// Correct
<ControlledInput control={control} name="email" isDisabled={loading} />
// Wrong
<ControlledInput control={control} name="email" disabled={loading} />Keyboard covering inputs
Cause: Missing KeyboardAvoidingView or wrong behavior
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1 }}
keyboardVerticalOffset={100}
>
{/* Form */}
</KeyboardAvoidingView>Button not responding to form submit
Cause: Wrong Button API usage
// Correct
<Button onPress={handleSubmit(onSubmit)}>Submit</Button>
// Wrong
<Button label="Submit" onPress={handleSubmit(onSubmit)} />Testing Forms
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from './login-form';
describe('LoginForm', () => {
it('validates email format', async () => {
const { getByTestId, getByText } = render(<LoginForm />);
const emailInput = getByTestId('email-input');
const submitButton = getByTestId('login-button');
fireEvent.changeText(emailInput, 'invalid-email');
fireEvent.press(submitButton);
await waitFor(() => {
expect(getByText('Invalid email')).toBeTruthy();
});
});
it('submits valid form', async () => {
const mockSignIn = jest.fn();
const { getByTestId } = render(<LoginForm onSignIn={mockSignIn} />);
fireEvent.changeText(getByTestId('email-input'), 'test@example.com');
fireEvent.changeText(getByTestId('password-input'), 'password123');
fireEvent.press(getByTestId('login-button'));
await waitFor(() => {
expect(mockSignIn).toHaveBeenCalled();
});
});
});Resources
- react-hook-form: https://react-hook-form.com/
- Zod: https://zod.dev/
- react-native-keyboard-controller: https://kirillzyusko.github.io/react-native-keyboard-controller/
- HeroUI Native TextField: https://v3.heroui.com/native/components/text-field
- Custom Components: Custom Components Guide
Summary
Forms in Caracal starter use:
- react-hook-form for state management
- Zod for validation with TypeScript inference
- ControlledInput wrapping HeroUI Native TextField
- HeroUI Native Button for submission
- react-native-keyboard-controller for keyboard handling
- Type safety throughout with TypeScript
This combination provides excellent developer experience with minimal code and maximum type safety.