Forms & Validation

This boilerplate uses React Hook Form with Zod for type-safe forms and validation, providing excellent developer experience and performance.

Why This Stack?

📝 React Hook Form

Performant, flexible forms with minimal re-renders

✅ Zod

TypeScript-first schema validation with type inference

🎯 Type Safety

Full TypeScript support from schema to form data

⚡ Performance

Efficient validation and minimal re-renders

Basic Form Example

typescript
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, TextInput, Text, Pressable } from 'react-native';

// 1. Define validation schema
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
});

// 2. Infer TypeScript type
type LoginForm = z.infer<typeof loginSchema>;

export function LoginScreen() {
  // 3. Setup form with validation
  const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  // 4. Handle submission
  const onSubmit = (data: LoginForm) => {
    console.log('Valid form data:', data);
    // Call API, etc.
  };

  return (
    <View className="p-4 gap-4">
      {/* Email Field */}
      <View>
        <Text className="text-sm font-medium text-white mb-2">Email</Text>
        <Controller
          control={control}
          name="email"
          render={({ field: { onChange, value } }) => (
            <TextInput
              value={value}
              onChangeText={onChange}
              placeholder="you@example.com"
              keyboardType="email-address"
              autoCapitalize="none"
              className="bg-white/10 border border-white/20 rounded-lg px-4 py-3 text-white"
            />
          )}
        />
        {errors.email && (
          <Text className="text-red-400 text-sm mt-1">
            {errors.email.message}
          </Text>
        )}
      </View>

      {/* Password Field */}
      <View>
        <Text className="text-sm font-medium text-white mb-2">Password</Text>
        <Controller
          control={control}
          name="password"
          render={({ field: { onChange, value } }) => (
            <TextInput
              value={value}
              onChangeText={onChange}
              placeholder="••••••••"
              secureTextEntry
              className="bg-white/10 border border-white/20 rounded-lg px-4 py-3 text-white"
            />
          )}
        />
        {errors.password && (
          <Text className="text-red-400 text-sm mt-1">
            {errors.password.message}
          </Text>
        )}
      </View>

      {/* Submit Button */}
      <Pressable
        onPress={handleSubmit(onSubmit)}
        className="bg-blue-500 rounded-lg py-3 mt-4"
      >
        <Text className="text-white font-semibold text-center">Login</Text>
      </Pressable>
    </View>
  );
}

Validation Schemas

Create reusable schemas in your domain layer:

src/domain/schemas/user.schema.ts
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(6, 'Min 6 characters'),
});

export const registerSchema = z.object({
  name: z.string().min(2, 'Name is required'),
  email: z.string().email('Invalid email'),
  password: z.string()
    .min(8, 'Min 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase')
    .regex(/[a-z]/, 'Must contain lowercase')
    .regex(/[0-9]/, 'Must contain number'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

export const profileSchema = z.object({
  name: z.string().min(2, 'Name is required'),
  bio: z.string().max(160, 'Bio is too long').optional(),
  website: z.string().url('Invalid URL').optional().or(z.literal('')),
  dateOfBirth: z.date().max(new Date(), 'Invalid date'),
});

export const taskSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  description: z.string().optional(),
  priority: z.enum(['low', 'medium', 'high']),
  dueDate: z.date().min(new Date(), 'Date must be in future').optional(),
  tags: z.array(z.string()),
});

// Export inferred types
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ProfileInput = z.infer<typeof profileSchema>;
export type TaskInput = z.infer<typeof taskSchema>;

Form in ViewModel

Keep form logic in ViewModels for cleaner code:

src/presentation/viewmodels/useLoginViewModel.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { router } from 'expo-router';
import { loginSchema, type LoginInput } from '@/domain/schemas/user.schema';
import { authApi } from '@/data/api/endpoints/auth';
import { useAuthStore } from '@/stores/auth.store';

export function useLoginViewModel() {
  const login = useAuthStore(state => state.login);
  
  const form = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const loginMutation = useMutation({
    mutationFn: authApi.login,
    onSuccess: (data) => {
      login(data.token, data.user);
      router.replace('/home');
    },
    onError: (error: Error) => {
      form.setError('root', {
        message: error.message,
      });
    },
  });

  const handleSubmit = form.handleSubmit((data) => {
    loginMutation.mutate(data);
  });

  return {
    form,
    handleSubmit,
    isLoading: loginMutation.isPending,
    error: form.formState.errors.root?.message,
  };
}
View using ViewModel
import { useLoginViewModel } from '@/presentation/viewmodels/useLoginViewModel';

export function LoginScreen() {
  const { form, handleSubmit, isLoading, error } = useLoginViewModel();
  const { control, formState: { errors } } = form;

  return (
    <View className="p-4">
      {error && (
        <View className="bg-red-500/10 border border-red-500 rounded-lg p-3 mb-4">
          <Text className="text-red-500">{error}</Text>
        </View>
      )}

      {/* Form fields... */}
      
      <Pressable
        onPress={handleSubmit}
        disabled={isLoading}
        className="bg-blue-500 rounded-lg py-3"
      >
        <Text className="text-white font-semibold text-center">
          {isLoading ? 'Loading...' : 'Login'}
        </Text>
      </Pressable>
    </View>
  );
}

Reusable Form Components

src/presentation/components/FormInput.tsx
import { Controller, Control, FieldPath, FieldValues } from 'react-hook-form';
import { View, Text, TextInput, TextInputProps } from 'react-native';
import { cn } from '@/utils/cn';

interface FormInputProps<T extends FieldValues> extends TextInputProps {
  control: Control<T>;
  name: FieldPath<T>;
  label: string;
  error?: string;
}

export function FormInput<T extends FieldValues>({
  control,
  name,
  label,
  error,
  ...inputProps
}: FormInputProps<T>) {
  return (
    <View>
      <Text className="text-sm font-medium text-white mb-2">{label}</Text>
      <Controller
        control={control}
        name={name}
        render={({ field: { onChange, value } }) => (
          <TextInput
            value={value as string}
            onChangeText={onChange}
            className={cn(
              'bg-white/10 border rounded-lg px-4 py-3 text-white',
              error ? 'border-red-500' : 'border-white/20'
            )}
            placeholderTextColor="#94a3b8"
            {...inputProps}
          />
        )}
      />
      {error && (
        <Text className="text-red-400 text-sm mt-1">{error}</Text>
      )}
    </View>
  );
}

// Usage
<FormInput
  control={control}
  name="email"
  label="Email"
  error={errors.email?.message}
  keyboardType="email-address"
  autoCapitalize="none"
/>

Select/Dropdown

typescript
import { Controller } from 'react-hook-form';
import { View, Text, Pressable } from 'react-native';

const priorities = ['low', 'medium', 'high'] as const;

<Controller
  control={control}
  name="priority"
  render={({ field: { onChange, value } }) => (
    <View className="flex-row gap-2">
      {priorities.map((priority) => (
        <Pressable
          key={priority}
          onPress={() => onChange(priority)}
          className={cn(
            'px-4 py-2 rounded-lg',
            value === priority
              ? 'bg-blue-500'
              : 'bg-white/10 border border-white/20'
          )}
        >
          <Text className="text-white capitalize">{priority}</Text>
        </Pressable>
      ))}
    </View>
  )}
/>

Checkbox/Switch

typescript
import { Controller } from 'react-hook-form';
import { View, Text, Switch } from 'react-native';

<Controller
  control={control}
  name="rememberMe"
  render={({ field: { onChange, value } }) => (
    <View className="flex-row items-center justify-between">
      <Text className="text-white">Remember me</Text>
      <Switch
        value={value}
        onValueChange={onChange}
        trackColor={{ false: '#767577', true: '#3b82f6' }}
      />
    </View>
  )}
/>

Date Picker

typescript
import { Controller } from 'react-hook-form';
import DateTimePicker from '@react-native-community/datetimepicker';
import { View, Text, Pressable } from 'react-native';
import { format } from 'date-fns';

<Controller
  control={control}
  name="dueDate"
  render={({ field: { onChange, value } }) => (
    <View>
      <Text className="text-sm font-medium text-white mb-2">Due Date</Text>
      <Pressable
        onPress={() => setShowPicker(true)}
        className="bg-white/10 border border-white/20 rounded-lg px-4 py-3"
      >
        <Text className="text-white">
          {value ? format(value, 'PPP') : 'Select date'}
        </Text>
      </Pressable>
      
      {showPicker && (
        <DateTimePicker
          value={value || new Date()}
          mode="date"
          onChange={(event, date) => {
            setShowPicker(false);
            if (date) onChange(date);
          }}
        />
      )}
    </View>
  )}
/>

Dynamic Fields

Use useFieldArray for dynamic lists:

typescript
import { useForm, useFieldArray } from 'react-hook-form';

const form = useForm({
  defaultValues: {
    tags: [{ value: '' }],
  },
});

const { fields, append, remove } = useFieldArray({
  control: form.control,
  name: 'tags',
});

return (
  <View>
    {fields.map((field, index) => (
      <View key={field.id} className="flex-row gap-2">
        <Controller
          control={form.control}
          name={`tags.${index}.value`}
          render={({ field }) => (
            <TextInput {...field} className="flex-1" />
          )}
        />
        <Pressable onPress={() => remove(index)}>
          <Text className="text-red-500">Remove</Text>
        </Pressable>
      </View>
    ))}
    
    <Pressable onPress={() => append({ value: '' })}>
      <Text className="text-blue-500">Add Tag</Text>
    </Pressable>
  </View>
);

Form State

typescript
const { formState } = useForm();

// Check validation status
const isValid = formState.isValid;
const isDirty = formState.isDirty; // Has user changed anything?
const isSubmitting = formState.isSubmitting;
const isSubmitted = formState.isSubmitted;

// Touched fields
const touchedFields = formState.touchedFields;

// All errors
const errors = formState.errors;

// Disable submit if invalid
<Pressable
  onPress={handleSubmit(onSubmit)}
  disabled={!isValid || isSubmitting}
  className={cn(
    'bg-blue-500 rounded-lg py-3',
    (!isValid || isSubmitting) && 'opacity-50'
  )}
>
  <Text className="text-white font-semibold text-center">
    {isSubmitting ? 'Submitting...' : 'Submit'}
  </Text>
</Pressable>

Custom Validation

typescript
const schema = z.object({
  username: z.string()
    .min(3, 'Min 3 characters')
    .refine(
      async (username) => {
        // Check if username is available
        const available = await checkUsernameAvailability(username);
        return available;
      },
      { message: 'Username is taken' }
    ),
  
  email: z.string()
    .email('Invalid email')
    .refine(
      (email) => !email.endsWith('@tempmail.com'),
      { message: 'Temporary emails not allowed' }
    ),
});

Best Practices

✅ Define schemas in domain layer

Keep validation logic separate from UI

✅ Use ViewModels for form logic

Keep Views simple and focused on rendering

✅ Create reusable components

Extract common form inputs into components

✅ Show errors clearly

Display validation errors immediately and clearly

✅ Disable submit when invalid

Prevent submission of invalid forms

Zod schemas provide both runtime validation and TypeScript types - you get type safety for free!

Next Steps