Validating Forms in React Native with React Hook Form
AI generated
RN
native
React Native · React Hook Form · Zod · Forms
Validating Forms in React Native with React Hook Form
from Controller to useFieldArray

React Native does not ship native HTML inputs, so the simple register pattern from the web does not carry over as is. React Hook Form solves this with the Controller wrapper, validates forms declaratively through a Zod schema, and keeps re-renders on mobile devices as low as possible, even for complex, nested forms built with useFieldArray.

18 min read useForm · Controller · Zod · useFieldArray React Native 0.74+ · Expo SDK 51+

1. Why React Hook Form works differently in React Native

React Hook Form was originally built for the web, where register() attaches a real DOM ref to an <input> element and the browser handles the rest. In React Native there is no DOM. TextInput, Switch and Picker are native components that communicate with native view objects on iOS and Android through the bridge or JSI. A ref to one of these components does not point to an HTML element with a .value property, it points to an instance with an entirely different imperative API. That is why the classic register() pattern familiar from React web tutorials does not work directly in React Native, and anyone who naively wires up a form with register quickly runs into TypeErrors or values that are silently ignored.

The second difference concerns performance expectations. On a mid-range Android device with limited CPU headroom, every re-render of a form screen costs noticeable time, especially when the form contains many fields, conditional sections, or nested layout calculations. A classic form built with one useState per field triggers a re-render of the entire form component on every keystroke, because the state lives in the same functional component scope as the JSX being rendered. For a form with ten fields and several validation rules, this adds up to noticeable keyboard lag, small delays while typing, and in extreme cases a brief visual flicker of the keyboard when Android momentarily loses focus during a re-layout.

React Hook Form solves both problems at once. It keeps form state outside the React render cycle in a ref-based internal structure and treats fields as uncontrolled inputs, so a keystroke in one field does not automatically re-render the entire React Native form. To wire up native components, the library provides the Controller wrapper, which bridges the internal form state with the imperative API of TextInput and similar components. This combination has made React Hook Form the de facto standard solution for forms in production React Native apps, from simple login screens to multi-step checkout forms with dynamic field lists.

2. useForm: the base setup for React Native forms

The entry point for every form is the useForm hook. It returns an object with the core methods control, handleSubmit, formState, watch, trigger and setValue, which together cover the complete form logic. In React Native, the control reference is especially important, since it gets passed down to every Controller and establishes the connection between individual fields and the central form state. Without control, no native input field can be wired to React Hook Form, which is why this object is the first thing destructured in every form component.

The second important building block is the mode parameter of useForm, which determines when validation runs: onSubmit validates only on submission, onBlur validates as soon as a field loses focus, and onChange validates on every keystroke. For React Native forms backed by a Zod schema, onBlur combined with reValidateMode: "onChange" is a solid compromise: the user sees no error message while still typing, but once an error has appeared, it disappears immediately as soon as the input is corrected. This fine-grained control matters more on mobile than on the web, because the smaller screen area and the on-screen keyboard leave little room for intrusive error text.


// FormRoot.jsx : useForm setup for a React Native login form
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { View, Text, TextInput, Pressable } from "react-native";

// Zod schema defines both types and validation rules in one place
const loginSchema = z.object({
  email: z.string().email("Please enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

export function LoginForm({ onSubmitLogin }) {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(loginSchema),
    mode: "onBlur",
    reValidateMode: "onChange",
    defaultValues: { email: "", password: "" },
  });

  const onSubmit = async (data) => {
    // data is already typed and validated by the Zod schema
    await onSubmitLogin(data);
  };

  return (
    <View style={{ padding: 24 }}>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            onChangeText={onChange}
            onBlur={onBlur}
            value={value}
            autoCapitalize="none"
            keyboardType="email-address"
            placeholder="Email"
            style={{ borderWidth: 1, borderColor: "#94a3b8", borderRadius: 8, padding: 12 }}
          />
        )}
      />
      {errors.email && <Text style={{ color: "#dc2626" }}>{errors.email.message}</Text>}

      <Pressable onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>
        <Text>Log in</Text>
      </Pressable>
    </View>
  );
}

3. Controller instead of register: wiring up native TextInput correctly

On the web, register("fieldName") lets you attach a ref directly to an <input>, because the browser provides a standardized ref API for form elements. React Native has no such standardization: TextInput exposes a ref object with methods like .focus() and .clear(), but no .value property that React Hook Form could read to get the current value. For this reason, the Controller wrapper in React Native is not optional, it is the only supported way to connect a native input field to React Hook Form. Anyone who tries to apply register directly to a TextInput component either gets a runtime error or a field whose value never reaches the form state.

Controller follows the render prop pattern: it renders the actual input component itself and passes down the three functions onChange, onBlur, and the current value inside the field object. These three values are passed through one to one to the corresponding props of the native component, which for TextInput means onChangeText, onBlur and value. The order matters here: onChangeText in React Native delivers the new string value directly, while onChange on the web delivers an event object. Anyone who writes onChange={onChange} out of habit instead of onChangeText={onChange} will not get an error in React Native, just a field that never stores a value, because TextInput calls the onChange prop with an event rather than the string.

For non-text components such as Switch, the Slider from @react-native-community/slider, or custom picker components from libraries like @react-native-picker/picker, Controller is equally mandatory, because each of these components has its own, inconsistent callback signature. Switch calls onValueChange with a boolean, a picker often calls onValueChange with a value and an index. Controller abstracts these differences away, letting you map the correct prop yourself inside the render callback while still working consistently with the same onChange from React Hook Form.

4. Schema validation with Zod and zodResolver

Zod defines validation rules as a TypeScript-first schema that simultaneously derives the types for the form state and handles the runtime check. Instead of scattering individual rules objects with required, minLength, and custom validators across every Controller, you define a single Zod object that centralizes all field rules. The zodResolver from @hookform/resolvers/zod translates this schema into a format React Hook Form understands internally, and returns a structured error object on every validation run that lands directly in formState.errors.

The practical benefit in React Native projects is reusability of the schema beyond the form layer. The same Zod schema that validates a login form can also check an API response or serve as the basis for generated TypeScript types, without maintaining validation logic twice. For more complex forms, Zod supports conditional validation through .refine() and .superRefine(), for example to check that a password confirmation field matches the original password, or that an end date falls after a start date. These rules cannot be meaningfully expressed as individual rules props on a Controller, because they span multiple fields at once.

Error messages from Zod land automatically in the correct language when you supply them directly in the schema as the second parameter of the respective validation method, such as z.string().min(8, "Password must be at least 8 characters"). For multi-language apps, you can instead return error codes and only translate them at the point of display in the component, which tends to be cleaner in React Native apps using i18n libraries like i18next than hardcoded strings inside the schema.


{
  "name": "rn-form-example",
  "dependencies": {
    "react-hook-form": "^7.52.0",
    "@hookform/resolvers": "^3.6.0",
    "zod": "^3.23.0",
    "react-native": "0.74.3",
    "expo": "~51.0.0"
  }
}

# Install React Hook Form with Zod resolver in an Expo project
npx expo install react-hook-form @hookform/resolvers zod

# Optional: field array helper types work out of the box, no extra package needed

5. Error display in native input components

Unlike the web, where CSS pseudo-classes such as :invalid and native browser tooltips handle part of the error display for you, in React Native every visual reaction to a validation error has to be coded explicitly in JSX. The usual approach is to destructure formState.errors from useForm and check per field whether an error object exists. If it does, the border color of the TextInput is typically set to red and a Text element with errors.fieldName.message is rendered underneath. It matters to destructure formState fully rather than reading individual properties in isolation, since React Hook Form uses an internal proxy object that only triggers re-renders for properties that are actually read.

For consistent display across an entire form, it is worth building a small helper component that bundles label, TextInput and error text, and calculates the border color dynamically based on the error state. On iOS and Android there is also the option to adjust accessibilityLabel and accessibilityHint in the error case, so that screen readers like VoiceOver and TalkBack announce the error state correctly. This matters more in mobile apps than on the web, because users of mobile screen readers more often go through an entire form by voice output alone, without visual context.

A common mistake is to show error text only after the first submit attempt, but then keep re-validating on every keystroke without configuring reValidateMode correctly. Without this setting, an error message that was shown once stays visible even though the user has already fixed the issue, until they submit the form again. In practice this leads to support requests because users think their correction is being ignored. mode: "onBlur" together with reValidateMode: "onChange" reliably solves this problem for most React Native forms.

6. Performance: uncontrolled inputs and minimizing re-renders

The central performance advantage of React Hook Form in React Native lies in treating form fields as uncontrolled inputs. In a classic form using one useState per field, every keystroke triggers a React state update, forcing React to re-render the entire form component and all its child components unless memo catches it. For a form with ten or more fields, that means a keystroke in field one also re-renders fields two through ten, even though nothing changed on them. On high-end devices this hardly shows up, but on an older or cheaper Android device with limited RAM and a slower CPU, this overhead quickly adds up to noticeable input lag.

React Hook Form keeps the current value of every field in an internal ref structure instead of React state. A keystroke updates this ref directly, without triggering a re-render of the parent form. Only the component actually rendered through Controller updates, and even that only if its value prop visibly changes. This reduces the number of re-renders for a typical ten-field form from potentially hundreds (one re-render per keystroke across all fields) to a handful of targeted updates, which makes the difference between smooth and choppy behavior especially noticeable when typing quickly on an on-screen keyboard.

A second performance aspect concerns error display itself. Because formState is implemented as a proxy, React Hook Form tracks which properties (errors, isDirty, isSubmitting) a component actually reads, and only re-renders that component when exactly those properties change. Anyone who accidentally passes the entire formState object instead of individually destructured properties down to a child component partially defeats this optimization, because the proxy tracking logic no longer applies cleanly. For React Native forms with many conditional fields, such as a multi-step checkout form, this selective re-render logic is the main reason the library feels noticeably more responsive than manual state management, especially on devices with less computing power.

7. Integrating custom RN input components with Controller

Most production React Native apps do not use the raw TextInput component directly, but a custom wrapper component with a label, icon, error text and consistent styling. For this custom component to work with React Hook Form, it only needs to pass through the three props value, onChangeText and onBlur to the outside world, regardless of how many additional props it accepts internally for styling or icons. The Controller wrapper takes care of the rest and does not care whether a raw TextInput or a multi-layered custom component sits behind it.

A clean pattern is to build the custom input component with forwardRef, so that Controller can also pass programmatic focus (ref.current.focus()) to the field when needed, for example to automatically jump to the first invalid field after a validation error. This combination of Controller and a custom, reusable input component is the standard approach in larger React Native codebases, because it connects design system consistency with the form logic of React Hook Form without every form component having to reimplement the styling.


// FormTextField.jsx : reusable RN input component wired to React Hook Form
import { forwardRef } from "react";
import { View, Text, TextInput } from "react-native";

// Only value, onChangeText and onBlur need to reach the outside world
export const FormTextField = forwardRef(function FormTextField(
  { label, value, onChangeText, onBlur, error, ...rest },
  ref
) {
  return (
    <View style={{ marginBottom: 16 }}>
      <Text style={{ marginBottom: 4, fontWeight: "600" }}>{label}</Text>
      <TextInput
        ref={ref}
        value={value}
        onChangeText={onChangeText}
        onBlur={onBlur}
        style={{
          borderWidth: 1,
          borderColor: error ? "#dc2626" : "#94a3b8",
          borderRadius: 8,
          padding: 12,
        }}
        {...rest}
      />
      {error && <Text style={{ color: "#dc2626", fontSize: 12 }}>{error}</Text>}
    </View>
  );
});

// Usage inside a form with Controller
// <Controller
//   control={control}
//   name="fullName"
//   render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
//     <FormTextField
//       label="Full name"
//       value={value}
//       onChangeText={onChange}
//       onBlur={onBlur}
//       error={error?.message}
//     />
//   )}
// />

8. watch, trigger, setValue and submit handling

watch observes the current value of one or more fields without requiring a separate state update, and is useful for conditional display, such as showing or hiding an extra field once a checkbox is checked. In React Native, some caution is needed: watch without an argument observes the entire form and triggers a re-render of the calling component on every change, which partially undoes the performance advantage from section six. It is better to call watch("fieldName") specifically for exactly the fields whose value actually drives conditional display, rather than observing the entire form object.

trigger fires a manual validation, either for a single field (trigger("email")) or the entire form (trigger()), and is useful for multi-step forms where you want to check whether the fields of the current step are valid before moving to the next step. setValue sets the value of a field programmatically, for example after a successful address autocomplete via an external API, and accepts an options flag shouldValidate to trigger validation right when the value is set, instead of waiting for the next user interaction.

Submit handling runs through handleSubmit(onValid, onInvalid), where onValid is only called once all Zod rules are satisfied, and receives the typed, already validated data as its parameter. The optional second parameter onInvalid receives the error object and is useful for automatically scrolling to the first invalid field after a failed submit, which in React Native is implemented with ScrollView refs and the position data from each field's onLayout. Together, watch, trigger, setValue and handleSubmit make up the complete imperative API needed for nearly every practical React Native form, without any additional state management library.

9. Arrays and nested fields with useFieldArray

As soon as a form contains a dynamic list of entries, such as several phone numbers, shipping addresses, or order line items, useForm alone is no longer enough. useFieldArray handles exactly this case: it returns an array of fields with stable id values along with the functions append, remove, move and insert to dynamically add, remove, or reorder entries. It matters that every rendered list item uses the id from useFieldArray as the React key, not the array index, because otherwise removing a middle entry can associate the wrong fields with the wrong values.

In React Native, useFieldArray is often combined with a FlatList or a simple map iteration inside a ScrollView. Every field within an array entry is bound to Controller through a composed name such as contacts.${index}.phoneNumber, so that React Hook Form correctly represents the nested structure in a single form object. Zod supports this structure natively via z.array(z.object({...})), so every entry in the list follows the same validation rules, and errors surface per index at errors.contacts[index].phoneNumber.

On the performance side, useFieldArray ensures that adding or removing an entry only re-renders the affected list, not the entire surrounding form with all its other, unchanged fields. For very long lists, say more than twenty entries in an order form, it is also worth extracting each row into its own component wrapped in React.memo, so that even within the list only the actually changed row gets recalculated. This combination of useFieldArray, stable keys, and memoized row components is the established approach for complex, dynamic React Native forms with nested data structures.


// ContactsFieldArray.jsx : dynamic list of nested fields with useFieldArray
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { View, Text, TextInput, Pressable } from "react-native";

const contactsSchema = z.object({
  contacts: z.array(
    z.object({
      name: z.string().min(1, "Name is required"),
      phoneNumber: z.string().min(6, "Phone number looks too short"),
    })
  ).min(1, "Add at least one contact"),
});

export function ContactsForm() {
  const { control, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(contactsSchema),
    defaultValues: { contacts: [{ name: "", phoneNumber: "" }] },
  });

  // fields carry a stable id, use it as the React key, never the array index
  const { fields, append, remove } = useFieldArray({ control, name: "contacts" });

  return (
    <View style={{ padding: 24 }}>
      {fields.map((field, index) => (
        <View key={field.id} style={{ marginBottom: 12 }}>
          <Controller
            control={control}
            name={`contacts.${index}.name`}
            render={({ field: { onChange, onBlur, value } }) => (
              <TextInput
                placeholder="Name"
                value={value}
                onChangeText={onChange}
                onBlur={onBlur}
              />
            )}
          />
          {errors.contacts?.[index]?.name && (
            <Text style={{ color: "#dc2626" }}>{errors.contacts[index].name.message}</Text>
          )}

          <Controller
            control={control}
            name={`contacts.${index}.phoneNumber`}
            render={({ field: { onChange, onBlur, value } }) => (
              <TextInput
                placeholder="Phone number"
                keyboardType="phone-pad"
                value={value}
                onChangeText={onChange}
                onBlur={onBlur}
              />
            )}
          />

          <Pressable onPress={() => remove(index)}>
            <Text>Remove contact</Text>
          </Pressable>
        </View>
      ))}

      <Pressable onPress={() => append({ name: "", phoneNumber: "" })}>
        <Text>Add contact</Text>
      </Pressable>
    </View>
  );
}

A direct comparison between manual form management, Formik, and React Hook Form shows why the library has become the standard specifically in React Native. Where manual useState turns every keystroke into a full re-render and Formik, despite using controlled components, still produces noticeable overhead through context-based updates, React Hook Form consistently works with uncontrolled, ref-based fields and thereby minimizes the number of re-renders to the technical minimum required.

Criterion Manual useState Formik React Hook Form
Re-renders per keystroke Entire form Form + context consumers Only the affected field
Wiring up native inputs Manual per field Manual per field Controller wrapper
Schema validation Custom functions Yup via adapter Zod via zodResolver
Dynamic field lists Manual array state handling FieldArray helper useFieldArray
Bundle size No extra library Larger Very small

Mironsoft

React Native apps, forms, and mobile architecture

Forms that stay smooth on every device?

We build React Native forms with React Hook Form, Zod schemas, and clean Controller integration that run without lag even on older Android devices.

Form audit

Reviewing existing React Native forms for re-render issues and validation gaps

Migration

Moving manual useState forms or Formik over to React Hook Form with Zod

Custom components

Wiring design system inputs cleanly into React Hook Form via Controller

10. Summary

React Hook Form solves two closely related problems in React Native: the missing native ref API on components like TextInput, and the performance cost of classic, state-based forms on mobile devices. The Controller wrapper is not an optional convenience layer here, it is the only supported way to correctly wire up native input components, because register() depends on a real DOM ref that does not exist in React Native. Zod schemas paired with zodResolver centralize validation logic, deliver typed form data, and extend cleanly to nested structures and conditional rules.

The performance advantage over manual useState or Formik comes from consistently using uncontrolled fields and a proxy-based formState implementation that limits re-renders to the components actually affected. watch, trigger and setValue round out this foundation with the imperative control needed for multi-step forms, conditional fields, and programmatic value changes. useFieldArray completes the picture by managing dynamic lists of nested fields without giving up the re-render discipline of the rest of the form. Taken together, React Hook Form combined with Zod is the most robust available solution for forms in production React Native apps.

Validating Forms in React Native with React Hook Form: The Key Takeaways

Controller instead of register

TextInput and friends have no DOM ref API. Controller is the only supported way to connect them to React Hook Form in React Native.

Zod schema instead of scattered rules

zodResolver bundles types and validation in one place, including .refine() for cross-field rules.

Performance through uncontrolled fields

Ref-based state and proxy formState minimize re-renders, noticeably so on lower-end Android devices.

useFieldArray for dynamic lists

Stable keys instead of array index, append/remove/move for nested form structures like contact lists.

11. FAQ: Validating Forms in React Native with React Hook Form

1Why doesn't register() work like on the web?
register() needs a DOM ref with .value. React Native components are native views without that API, so Controller is the only connection.
2Is Controller optional in React Native?
No, for TextInput, Switch, Picker, and similar native components Controller is mandatory, because register() relies on a DOM API that does not exist.
3Why is it more performant than useState forms?
Ref-based state instead of React state, uncontrolled fields, no re-render of the whole form per keystroke.
4How do you connect Zod to React Hook Form?
With zodResolver from @hookform/resolvers/zod as the resolver option in useForm. One central schema for types and validation.
5How do you show errors in TextInput?
Check errors.fieldName from formState, adjust the border color, render message in a Text element underneath.
6When to use watch instead of formState.errors?
watch for conditional display logic. For validation errors, formState.errors remains the correct source.
7How do you integrate custom input components?
Pass value, onChangeText, and onBlur through to the outside, Controller handles the rest regardless of internal props.
8What is useFieldArray needed for?
For dynamic lists like multiple contacts, with append, remove, move, insert, and a stable ID per entry.
9Why not use the array index as key?
Removing an entry shifts indexes, values can end up on the wrong rows. Use the stable id from useFieldArray instead.
10trigger versus handleSubmit?
trigger validates manually without submitting, useful in multi-step forms. handleSubmit validates and submits at the same time.