Forms at Scale

React Hook Form with a schema resolver, where the schema lives, and the point at which hand-rolled form state stops paying.

Syntaxconst { control, handleSubmit, formState } = useForm<Values>({ resolver: zodResolver(schema) })

Hand-rolled state is right for one or two fields. By six it is not: you are maintaining a value, an error and a touched flag per field, plus cross-field rules and submit state. That is the point to bring in a library.

React Hook Form

useForm returns control, handleSubmit, formState, reset, setValue and more. Its options include defaultValues, resolver and mode, which takes 'onSubmit' (the default), 'onBlur', 'onChange', 'onTouched' or 'all'. Set 'onBlur' to get the timing from the validation lesson for free.

register relies on DOM refs and is not the React Native path. Use <Controller> instead. Its render prop is called with { field, fieldState, formState }; field holds value, onChange, onBlur, name, ref and disabled, and fieldState holds error, invalid, isTouched and isDirty.

Where the schema belongs

In its own module, not inside the component. A resolver such as zodResolver from @hookform/resolvers/zod turns one schema into both the validation rules and, through z.infer, the TypeScript type for the values. The same module can then be imported by whatever else needs those rules.

Shorthand and longhand

z.email() is the Zod 4 form of z.string().email(); both work. Spreading {...field} onto a TextInput is shorthand for passing value, onBlur and ref by hand, but field.onChange lands on the wrong prop: wire onChangeText yourself.

Example

Example · tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { z } from "zod";

// One schema, two outputs: the rules below and the type on the next line.
const profileSchema = z.object({
  name: z.string().trim().min(2, "Enter your full name"),
  email: z.email({ message: "An email address needs an @" }),
});

type ProfileValues = z.infer<typeof profileSchema>;

type ProfileFormProps = {
  onSave: (values: ProfileValues) => Promise<void>;
};

// On screen: a name field and an email field, each with its message underneath,
// and a Save button that reads "Saving" and stops responding while onSave is
// pending. Every field in the schema needs a Controller, or the resolver keeps
// failing on a field the user cannot see.
export default function ProfileForm({ onSave }: ProfileFormProps) {
  const { control, handleSubmit, formState } = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    mode: "onBlur", // check a field when focus leaves it
    defaultValues: { name: "", email: "" },
  });

  return (
    <View style={styles.container}>
      <Controller
        control={control}
        name="name"
        render={({ field, fieldState }) => (
          <View style={styles.field}>
            <Text style={styles.label}>Full name</Text>
            <TextInput
              style={styles.input}
              value={field.value}
              onChangeText={field.onChange}
              onBlur={field.onBlur}
              autoCapitalize="words"
            />
            <Text style={styles.error}>{fieldState.error?.message ?? ""}</Text>
          </View>
        )}
      />
      <Controller
        control={control}
        name="email"
        render={({ field, fieldState }) => (
          <View style={styles.field}>
            <Text style={styles.label}>Email</Text>
            <TextInput
              style={styles.input}
              value={field.value}
              onChangeText={field.onChange} // takes the value itself, not an event
              onBlur={field.onBlur}         // marks the field touched and validates
              keyboardType="email-address"
              autoCapitalize="none"
            />
            <Text style={styles.error}>{fieldState.error?.message ?? ""}</Text>
          </View>
        )}
      />
      <Pressable
        style={styles.button}
        disabled={formState.isSubmitting}
        onPress={handleSubmit(onSave)} // validates, then calls onSave with parsed values
      >
        <Text style={styles.buttonText}>
          {formState.isSubmitting ? "Saving" : "Save"}
        </Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 8 },
  field: { gap: 2 },
  label: { fontSize: 13, fontWeight: "600" },
  input: { borderWidth: 1, borderColor: "#c6c6c8", borderRadius: 8, padding: 10 },
  error: { minHeight: 16, color: "#d70015", fontSize: 12 },
  button: { backgroundColor: "#0a84ff", borderRadius: 8, padding: 14, alignItems: "center" },
  buttonText: { color: "#fff", fontWeight: "600" },
});

When to use it

  • A sign-up form of six or more fields where hand-rolled state has become repetitive.
  • A rule that spans two fields, such as a password and its confirmation.
  • Sharing one set of validation rules between a form and the code that stores the values.
  • Keeping a submit button disabled until the form is both valid and changed.

More examples

The schema in its own module

A schema file is testable without rendering anything, and safeParse is how you check it. refine with a path is the answer to "where do cross-field rules live": not in the component, and not duplicated in two places. The same file gives the form its TypeScript type through z.infer.

Example · tsx
// schemas/signUp.ts - imported by the form, and by anything else
// that needs the same rules.
import { z } from "zod";

export const signUpSchema = z
  .object({
    email: z.email({ message: "An email address needs an @" }),
    password: z.string().min(8, "Use at least 8 characters"),
    confirm: z.string(),
    acceptsTerms: z.literal(true, { message: "You must accept the terms" }),
  })
  // Cross-field rules go here, and the message is attached to a path
  // so the error appears under the right field.
  .refine((values) => values.password === values.confirm, {
    message: "The passwords do not match",
    path: ["confirm"],
  });

export type SignUpValues = z.infer<typeof signUpSchema>;

const result = signUpSchema.safeParse({
  email: "[email protected]",
  password: "longenough",
  confirm: "different",
  acceptsTerms: true,
});
console.log(result.success);
// Output: false
console.log(result.success === false ? result.error.issues[0].message : "");
// Output: The passwords do not match

Controller around a Switch

Controller is not only for text. Any controlled component works as long as you map field.onChange to whatever prop that component uses to report a new value: onValueChange for Switch and Slider, onChangeText for TextInput. Passing control down as a prop keeps each field a small component of its own.

Example · tsx
import { Controller, type Control } from "react-hook-form";
import { StyleSheet, Switch, Text, View } from "react-native";
import type { SignUpValues } from "../schemas/signUp";

type TermsFieldProps = {
  control: Control<SignUpValues>;
};

export default function TermsField({ control }: TermsFieldProps) {
  return (
    <Controller
      control={control}
      name="acceptsTerms"
      render={({ field, fieldState }) => (
        <View style={styles.wrapper}>
          <View style={styles.row}>
            <Text style={styles.label}>I accept the terms</Text>
            {/* Do NOT spread {...field} here: Switch has no onChange prop of
                that shape. Pass value and onValueChange explicitly. */}
            <Switch value={field.value} onValueChange={field.onChange} />
          </View>
          <Text style={styles.error}>{fieldState.error?.message ?? ""}</Text>
        </View>
      )}
    />
  );
}

const styles = StyleSheet.create({
  wrapper: { gap: 2 },
  row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
  label: { fontSize: 16 },
  error: { minHeight: 16, color: "#d70015", fontSize: 12 },
});

Resetting and focusing after submit

handleSubmit awaits an async handler, so isSubmitting is true for its whole duration without you tracking a flag. isDirty keeps the button inert until something has been typed. Destructuring formState at render time is what makes the Proxy subscribe; read it inside the callback instead and the button never updates.

Example · tsx
import { useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";

type NoteValues = { body: string };

export default function QuickNote() {
  const inputRef = useRef<TextInput>(null);
  const { control, handleSubmit, reset, formState } = useForm<NoteValues>({
    defaultValues: { body: "" },
  });

  async function save(values: NoteValues) {
    console.log("saving", values.body);
    reset();                      // back to defaultValues, errors cleared
    inputRef.current?.focus();    // keep the keyboard up for the next note
  }

  // Subscribed during render, so changes re-render this component.
  const { isSubmitting, isDirty } = formState;

  return (
    <View style={styles.container}>
      <Controller
        control={control}
        name="body"
        render={({ field }) => (
          <TextInput
            ref={inputRef}
            style={styles.input}
            placeholder="Quick note"
            multiline
            value={field.value}
            onChangeText={field.onChange} // without this the form never sees the text
            onBlur={field.onBlur}
          />
        )}
      />
      <Pressable
        style={styles.button}
        disabled={isSubmitting || !isDirty}
        onPress={handleSubmit(save)}
      >
        <Text style={styles.buttonText}>{isSubmitting ? "Saving" : "Add note"}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 8 },
  input: { borderWidth: 1, borderColor: "#c6c6c8", borderRadius: 8, padding: 10, minHeight: 72 },
  button: { backgroundColor: "#0a84ff", borderRadius: 8, padding: 14, alignItems: "center" },
  buttonText: { color: "#fff", fontWeight: "600" },
});

Discussion

  • Be the first to comment on this lesson.