Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 9 min read

React Native Form Validation: The 2026 Setup

Every app has a form, and every form is where users quit. The pattern that holds up in 2026 is small: react-hook-form for state, Zod for the rules, one schema shared with your backend. The React Native twist is that register()doesn’t work — there’s no DOM — so you wrap inputs in Controller. This guide is the full working signup form, plus the four things that make validation feel good instead of punitive.

Pick the stack once

ApproachWorks in React Native?Schema reuseRe-rendersWhen to use it
react-hook-form + ZodYes — via ControllerShared with backendPer-field, minimalThe default for new apps
react-hook-form + YupYes — via ControllerShared, weaker TS inferencePer-field, minimalFine if Yup is already in the codebase
Formik + YupYesSharedWhole form on each keystrokeExisting projects only
useState by handYesNoneWhatever you writeOne or two fields, no rules

The re-render column is the one that shows up on a mid-range Android phone. Formik re-renders the entire form on every keystroke; react-hook-form keeps field state in refs and only re-renders what changed. On a three-field login you will never notice. On a twelve-field onboarding flow you will.

Step 1 — Install

npx expo install react-hook-form zod @hookform/resolvers

All three are pure JavaScript — no native modules, no rebuild, and they work in Expo Go. That is unusual enough in React Native to be worth saying out loud.

Step 2 — One schema, two consumers

Write the rules once, in a file your app and your API can both import. This is the whole argument for Zod over hand-rolled checks:

// lib/schemas/signup.ts
import { z } from 'zod';

export const signupSchema = z
  .object({
    email: z.string().min(1, 'Email is required').email('That email looks off'),
    password: z
      .string()
      .min(8, 'At least 8 characters')
      .regex(/[0-9]/, 'Include at least one number'),
    confirm: z.string(),
  })
  .refine((v) => v.password === v.confirm, {
    message: "Passwords don't match",
    path: ['confirm'],          // attach the error to the confirm field
  });

export type SignupValues = z.infer<typeof signupSchema>;

Two details that pay off later: every message is written for a human (“That email looks off”, not “Invalid email”), and path in the refine puts the mismatch error on the confirm field instead of floating at the top of the form where nobody connects it to an input.

Step 3 — The complete form

This is a full working screen. The important part is Controller: it takes the place of register() and hands you onChange, onBlur, and value to pass into a plain TextInput.

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  KeyboardAvoidingView, Platform, Pressable, ScrollView,
  StyleSheet, Text, TextInput, View,
} from 'react-native';
import { signupSchema, type SignupValues } from '../lib/schemas/signup';

export default function SignupScreen() {
  const {
    control,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
    mode: 'onTouched',                 // validate after first blur, then live
    defaultValues: { email: '', password: '', confirm: '' },
  });

  const onSubmit = async (values: SignupValues) => {
    const res = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
    if (res.status === 409) {
      // server rejected it — put the error back on the field that caused it
      setError('email', { message: 'That email is already registered' });
      return;
    }
    // navigate on success…
  };

  return (
    <KeyboardAvoidingView
      style={{ flex: 1 }}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
    >
      <ScrollView
        contentContainerStyle={styles.body}
        keyboardShouldPersistTaps="handled"
      >
        <Field label="Email" error={errors.email?.message}>
          <Controller
            control={control}
            name="email"
            render={({ field: { onChange, onBlur, value } }) => (
              <TextInput
                style={[styles.input, errors.email && styles.inputError]}
                value={value}
                onChangeText={onChange}
                onBlur={onBlur}
                placeholder="you@example.com"
                placeholderTextColor="#666"
                autoCapitalize="none"
                autoComplete="email"
                keyboardType="email-address"
              />
            )}
          />
        </Field>

        <Field label="Password" error={errors.password?.message}>
          <Controller
            control={control}
            name="password"
            render={({ field: { onChange, onBlur, value } }) => (
              <TextInput
                style={[styles.input, errors.password && styles.inputError]}
                value={value}
                onChangeText={onChange}
                onBlur={onBlur}
                secureTextEntry
                autoComplete="new-password"
              />
            )}
          />
        </Field>

        <Pressable
          style={[styles.button, isSubmitting && { opacity: 0.6 }]}
          disabled={isSubmitting}
          onPress={handleSubmit(onSubmit)}
        >
          <Text style={styles.buttonText}>
            {isSubmitting ? 'Creating account…' : 'Create account'}
          </Text>
        </Pressable>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

function Field({ label, error, children }) {
  return (
    <View style={{ marginBottom: 18 }}>
      <Text style={styles.label}>{label}</Text>
      {children}
      {!!error && <Text style={styles.error}>{error}</Text>}
    </View>
  );
}

const styles = StyleSheet.create({
  body: { padding: 24, paddingTop: 60 },
  label: { color: '#fff', marginBottom: 6, fontWeight: '600' },
  input: {
    borderWidth: 1, borderColor: '#333', borderRadius: 10,
    padding: 14, color: '#fff', backgroundColor: '#141414',
  },
  inputError: { borderColor: '#f87171' },
  error: { color: '#f87171', marginTop: 6, fontSize: 13 },
  button: { backgroundColor: '#fb923c', borderRadius: 10, padding: 16, alignItems: 'center' },
  buttonText: { color: '#1c1c1c', fontWeight: '700' },
});

Note handleSubmit(onSubmit) — it runs validation first and only calls your function with typed, valid values. You never check the fields yourself inside onSubmit.

The four things that actually break

  1. The submit button eats the first tap. Inside a ScrollView, the first tap while the keyboard is open just dismisses the keyboard. Set keyboardShouldPersistTaps="handled"and the tap lands on the button. This is the single most common “my form doesn’t submit” bug.
  2. Errors render behind the keyboard. An error banner at the top of a scrolled form is invisible on a phone. Put each message directly under its input, and add KeyboardAvoidingView so the focused field stays in view.
  3. Validating on every keystroke. mode: 'onChange' marks j@ as an invalid email while the user is still typing their address. 'onTouched'waits for the first blur, then updates live while they fix it — strict where it helps, quiet where it doesn’t.
  4. Server errors with nowhere to go.“Email already registered” is only knowable on the server. setError puts that message back on the email field so it renders in the same place as every other error, instead of in an alert the user has to dismiss and then re-find the field.

Client validation is not security

Everything above is a user-experience feature. Anyone can post directly to your endpoint and skip the app entirely. Because the schema is a plain module, the server check is three lines:

import { signupSchema } from '../lib/schemas/signup';

const parsed = signupSchema.safeParse(await req.json());
if (!parsed.success) {
  return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
}
// parsed.data is now typed and trusted

If you are storing this data in Postgres, the same discipline applies one layer down — see adding a real database for row-level security, which is the check that survives even a compromised API key.

The shortcut

Forms are the most repetitive code in a mobile app and the least interesting to write for the fifth time. Describe the fields and the rules in a sentence — “signup with email, a password of at least 8 characters with a number, and a matching confirm field” — and ShipNative generates the schema, the Controller wiring, the error states, and the keyboard handling as real React Native you can preview on your phone and export in full. Then you spend your time on the part of the product only you can write.

Frequently Asked Questions

What is the best form validation library for React Native?

react-hook-form with a Zod resolver is the default in 2026. react-hook-form works in React Native unchanged (you wrap each input in Controller instead of using register), and Zod gives you one schema you can reuse on your backend. Formik still works but is much less actively maintained.

Why does register() not work in React Native?

register() relies on DOM refs and native input events that React Native does not have. In React Native you use the Controller component (or the useController hook), which wires value and onChangeText into react-hook-form manually. Everything else in the library behaves the same.

Should I validate on every keystroke?

No. The default onSubmit mode is right for most forms — validating while someone is still typing marks a half-typed email as wrong and feels hostile. The useful compromise is mode: "onTouched", which validates a field the first time it loses focus and then live-updates as the user fixes it.

Can I reuse the same validation schema on my backend?

Yes, and it is the main reason to pick Zod. Put the schema in a shared file, import it in the app for form validation and in your API route or edge function to re-check the payload. Client validation is a UX feature; the server check is the one that protects your data.

How do I stop the keyboard covering my inputs and errors?

Wrap the form in KeyboardAvoidingView with behavior="padding" on iOS, put it inside a ScrollView with keyboardShouldPersistTaps="handled" so taps on the submit button register on the first press, and render each error message directly under its input so it is never hidden behind the keyboard.

Can an AI app builder generate validated forms?

Yes. Describe the fields and the rules ("signup with email, password at least 8 characters with one number, and a matching confirm field") and ShipNative generates the form, the schema, the error states, and the keyboard handling as real React Native code you can export.

→

React Native Authentication in 2026

The signup form is step one — this is everything behind it.

Read guide →
→

Add a Real Database

Where validated form data should actually land.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.