Caracal Starter
UI & Theming

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:

  1. react-hook-form — Form state management
  2. Zod — Schema validation with TypeScript inference
  3. 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 useController hook 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 testID props — essential for testing
  • Provide defaultValues — prevents uncontrolled-to-controlled warnings
  • Use proper keyboard types — email-address, phone-pad, numeric, etc.
  • Disable autoCapitalize for emails/usernames — better UX
  • Show loading states — use isLoading prop on Button
  • Reset form after successful submission — use reset() from useForm
  • 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 KeyboardAvoidingView from react-native — use from react-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


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.

On this page