Building a Reusable React Native Input Component with React Hook Form

Arnab ParyaliArnab Paryali
5 min read

When developing forms in React Native applications, we frequently encounter these challenges:

  1. Repetitive Code: Setting up input fields with proper styling and behavior for each form

  2. Validation Complexity: Managing form validation and error states consistently

  3. Customization Needs: Supporting various input accessories like icons and buttons

  4. Type Safety: Ensuring form field names match the data structure

These challenges often lead to verbose, inconsistent code that's difficult to maintain. Let's explore how to build a reusable solution.

The Development Process

1. Identifying Requirements

Our component needs to:

  • Integrate with React Hook Form for validation and state management

  • Support standard React Native TextInput props

  • Allow for custom elements on either side of the input

  • Handle error display consistently

  • Maintain type safety with TypeScript

2. Planning the Interface

We'll design a TypeScript interface extending the standard TextInput props:

export interface RHFInputProps<T extends FieldValues> extends TextInputProps {
  control: Control<T, any>; // React Hook Form control object
  name: Path<T>;            // Type-safe field name
  left?: ReactNode;         // Optional left accessory
  right?: ReactNode;        // Optional right accessory
}

The generic type parameter ensures that field names must exist in the form schema.

3. Integrating with React Hook Form

The core integration uses React Hook Form's Controller component to bridge between the form library and our UI component:

<Controller
  control={control}
  name={name}
  render={({
    field: { onChange, ...fieldProps },
    fieldState: { error },
  }) => (
    // Render the input with validation state
  )}
/>

This gives us access to:

  • The field's current value

  • Change handlers

  • Validation state and error messages

4. Designing the Component Structure

We need a layout that:

  • Positions the left and right accessories properly

  • Applies consistent styling

  • Displays error messages when validation fails

A nested View structure handles this well:

<View>
  <View style={styles.input}>
    {left}
    <TextInput ... />
    {right}
  </View>
  {error && <Text>{error.message}</Text>}
</View>

5. Styling for Consistency

We define styles outside the component to prevent recreation on each render:

const HEIGHT = 50;
const styles = StyleSheet.create({
  input: {
    height: HEIGHT,
    borderRadius: design.radius,
    borderWidth: 1,
    borderColor: "gray",
    paddingHorizontal: 12,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
  },
  textInput: { flex: 1, height: HEIGHT, fontSize: fonts.size.md },
});

This approach uses design tokens to maintain consistency with the rest of the application.

The Final Component

Here's the complete implementation:

import React, { ReactNode } from "react";
import {
  StyleSheet,
  Text,
  TextInput,
  TextInputProps,
  View,
} from "react-native";
import { Control, Controller, FieldValues, Path } from "react-hook-form";
import { design } from "@/theme";
import { fonts } from "@/fonts";

export interface RHFInputProps<T extends FieldValues> extends TextInputProps {
  control: Control<T, any>;
  name: Path<T>;
  left?: ReactNode;
  right?: ReactNode;
}

const RhfInput = <T extends FieldValues>({
  control,
  name,
  left,
  right,
  ...rest
}: RHFInputProps<T>) => {
  return (
    <Controller
      control={control}
      name={name}
      render={({
        field: { onChange, ...fieldProps },
        fieldState: { error },
      }) => (
        <View>
          <View style={styles.input}>
            {left}
            <TextInput
              style={styles.textInput}
              onChangeText={onChange}
              {...fieldProps}
              placeholderTextColor={"grey"}
              {...rest}
            />
            {right}
          </View>
          {error && <Text>{error.message}</Text>}
        </View>
      )}
    />
  );
};

RhfInput.displayName = "RhfInput";

const HEIGHT = 50;
const styles = StyleSheet.create({
  input: {
    height: HEIGHT,
    borderRadius: design.radius,
    borderWidth: 1,
    borderColor: "gray",
    paddingHorizontal: 12,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
  },
  textInput: { flex: 1, height: HEIGHT, fontSize: fonts.size.md },
});

export default RhfInput;

Usage Example

import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Text, ScrollView } from 'react-native';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Ionicons } from '@expo/vector-icons';
import RhfInput from './components/RhfInput'; // Import your custom component

// Define the form data structure with TypeScript
type SignupFormData = {
  fullName: string;
  email: string;
  phone: string;
  password: string;
  confirmPassword: string;
};

// Create validation schema with Yup
const validationSchema = yup.object({
  fullName: yup
    .string()
    .required('Full name is required')
    .min(2, 'Name must be at least 2 characters'),

  email: yup
    .string()
    .email('Please enter a valid email')
    .required('Email is required'),

  phone: yup
    .string()
    .matches(/^[0-9]{10}$/, 'Please enter a valid 10-digit phone number')
    .required('Phone number is required'),

  password: yup
    .string()
    .required('Password is required')
    .min(8, 'Password must be at least 8 characters')
    .matches(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
      'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'
    ),

  confirmPassword: yup
    .string()
    .required('Please confirm your password')
    .oneOf([yup.ref('password')], 'Passwords must match')
});

export default function SignupScreen() {
  // Initialize form with React Hook Form
  const { control, handleSubmit, formState: { errors } } = useForm<SignupFormData>({
    defaultValues: {
      fullName: '',
      email: '',
      phone: '',
      password: '',
      confirmPassword: ''
    },
    resolver: yupResolver(validationSchema) // Apply Yup validation
  });

  // Toggle password visibility state
  const [showPassword, setShowPassword] = useState(false);
  const [showConfirmPassword, setShowConfirmPassword] = useState(false);

  // Form submission handler
  const onSubmit = (data: SignupFormData) => {
    console.log('Form submitted:', data);
    // Implement your signup logic here
    // e.g., API call to create user account
  };

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.title}>Create Account</Text>

      {/* Full Name Input */}
      <RhfInput
        control={control} // Pass the form control
        name="fullName" // Field name must match the form data type
        placeholder="Full Name"
        autoCapitalize="words"
        // Left accessory - icon
        left={<Ionicons name="person-outline" size={20} color="gray" />}
      />

      {/* Email Input */}
      <RhfInput
        control={control}
        name="email"
        placeholder="Email Address"
        keyboardType="email-address"
        autoCapitalize="none"
        left={<Ionicons name="mail-outline" size={20} color="gray" />}
      />

      {/* Phone Input */}
      <RhfInput
        control={control}
        name="phone"
        placeholder="Phone Number"
        keyboardType="phone-pad"
        left={<Ionicons name="call-outline" size={20} color="gray" />}
      />

      {/* Password Input */}
      <RhfInput
        control={control}
        name="password"
        placeholder="Password"
        secureTextEntry={!showPassword} // Toggle based on state
        left={<Ionicons name="lock-closed-outline" size={20} color="gray" />}
        // Right accessory - interactive button to toggle password visibility
        right={
          <TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
            <Ionicons 
              name={showPassword ? "eye-off-outline" : "eye-outline"} 
              size={20} 
              color="gray" 
            />
          </TouchableOpacity>
        }
      />

      {/* Confirm Password Input */}
      <RhfInput
        control={control}
        name="confirmPassword"
        placeholder="Confirm Password"
        secureTextEntry={!showConfirmPassword}
        left={<Ionicons name="lock-closed-outline" size={20} color="gray" />}
        right={
          <TouchableOpacity onPress={() => setShowConfirmPassword(!showConfirmPassword)}>
            <Ionicons 
              name={showConfirmPassword ? "eye-off-outline" : "eye-outline"} 
              size={20} 
              color="gray" 
            />
          </TouchableOpacity>
        }
      />

      {/* Submit Button */}
      <TouchableOpacity 
        style={styles.button} 
        onPress={handleSubmit(onSubmit)}
      >
        <Text style={styles.buttonText}>Sign Up</Text>
      </TouchableOpacity>
    </ScrollView>
  );
}

// Styles for the signup form
const styles = StyleSheet.create({
  container: {
    padding: 24,
    gap: 16,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 24,
    textAlign: 'center',
  },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    height: 50,
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 16,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

This example demonstrates a complete signup form implementation that showcases:

  1. Proper TypeScript integration with form data structure

  2. Comprehensive validation with Yup

  3. Multiple field types with appropriate keyboard configurations

  4. Icon accessories on both sides of inputs

  5. Interactive elements (password visibility toggles)

  6. Form submission handling

The comments explain each component of the implementation, making it easy to understand how the RhfInput component integrates with React Hook Form.

0
Subscribe to my newsletter

Read articles from Arnab Paryali directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arnab Paryali
Arnab Paryali

Contai, West Bengal, India