Mobile Apps with React Native and Expo · State, Data, Offline and Sign-In

State and Forms That Feel Native

Build a sign-up form with TextInput: controlled inputs, a reducer for form state, validation that shows errors at the right moment, the right keyboard for each field, and a keyboard that doesn't cover the button.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A sign-up form with name, email and password fields. Errors appear after a user leaves a field (not while they're still typing), the email field shows the email keyboard, "Next" moves to the next field, and the submit button stays visible above the keyboard.

The idea

  • Controlled inputs: each TextInput gets its value from state and reports changes with onChangeText.
  • A reducer keeps related form state (values, which fields were touched, submitting) in one place, so updates stay predictable as the form grows.
  • Validate in a pure function that takes values and returns errors. Show an error only when the field has been touched (blurred) or after a submit attempt.
  • Mobile details matter: keyboardType="email-address", autoCapitalize="none", secureTextEntry for passwords, returnKeyType="next" with refs to focus the next field, textContentType/autoComplete for password managers, and KeyboardAvoidingView so the keyboard doesn't cover the button.

The code

import { useReducer, useRef } from "react";
import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from "react-native";
import { initialForm, formReducer, validate } from "./signupForm";   // the logic below

export default function SignUp({ onSubmit }: { onSubmit: (v: { name: string; email: string; password: string }) => Promise<void> }) {
  const [form, dispatch] = useReducer(formReducer, initialForm);
  const emailRef = useRef<TextInput>(null);
  const passwordRef = useRef<TextInput>(null);
  const errors = validate(form.values);
  const show = (field: keyof typeof errors) => (form.touched[field] || form.submitted) && errors[field];

  async function submit() {
    dispatch({ type: "submit" });
    if (Object.keys(errors).length) return;
    await onSubmit(form.values);
  }

  return (
    <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1, padding: 16, gap: 8 }}>
      <TextInput placeholder="Name" value={form.values.name} autoComplete="name"
        onChangeText={(v) => dispatch({ type: "change", field: "name", value: v })}
        onBlur={() => dispatch({ type: "blur", field: "name" })}
        returnKeyType="next" onSubmitEditing={() => emailRef.current?.focus()} />
      {show("name") && <Text style={{ color: "#b91c1c" }}>{errors.name}</Text>}

      <TextInput ref={emailRef} placeholder="Email" value={form.values.email}
        keyboardType="email-address" autoCapitalize="none" autoComplete="email" textContentType="emailAddress"
        onChangeText={(v) => dispatch({ type: "change", field: "email", value: v })}
        onBlur={() => dispatch({ type: "blur", field: "email" })}
        returnKeyType="next" onSubmitEditing={() => passwordRef.current?.focus()} />
      {show("email") && <Text style={{ color: "#b91c1c" }}>{errors.email}</Text>}

      <TextInput ref={passwordRef} placeholder="Password" value={form.values.password}
        secureTextEntry textContentType="newPassword" autoComplete="new-password"
        onChangeText={(v) => dispatch({ type: "change", field: "password", value: v })}
        onBlur={() => dispatch({ type: "blur", field: "password" })}
        returnKeyType="go" onSubmitEditing={submit} />
      {show("password") && <Text style={{ color: "#b91c1c" }}>{errors.password}</Text>}

      <Pressable onPress={submit} accessibilityRole="button" style={{ backgroundColor: "#0f766e", padding: 14, borderRadius: 10 }}>
        <Text style={{ color: "white", textAlign: "center" }}>Create account</Text>
      </Pressable>
    </KeyboardAvoidingView>
  );
}

Try the logic

The form's reducer and validation are plain TypeScript, so you can run a whole user session without a phone.

Form reducer and validation, step by stepTry it yourself
type Field = "name" | "email" | "password";
type Values = Record<Field, string>;
type Form = { values: Values; touched: Partial<Record<Field, boolean>>; submitted: boolean };
type Action =
  | { type: "change"; field: Field; value: string }
  | { type: "blur"; field: Field }
  | { type: "submit" };

const initialForm: Form = { values: { name: "", email: "", password: "" }, touched: {}, submitted: false };

function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "change":
      return { ...state, values: { ...state.values, [action.field]: action.value } };
    case "blur":
      return { ...state, touched: { ...state.touched, [action.field]: true } };
    case "submit":
      return { ...state, submitted: true };
  }
}

function validate(v: Values): Partial<Record<Field, string>> {
  const errors: Partial<Record<Field, string>> = {};
  if (!v.name.trim()) errors.name = "Enter your name";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) errors.email = "Enter a valid email";
  if (v.password.length < 8) errors.password = "Use at least 8 characters";
  return errors;
}

function visibleErrors(form: Form): string {
  const errors = validate(form.values);
  const shown = (Object.keys(errors) as Field[]).filter((f) => form.touched[f] || form.submitted);
  return shown.map((f) => `${f}: ${errors[f]}`).join(" | ") || "(no errors shown)";
}

let form = initialForm;
const step = (label: string, action: Action) => {
  form = formReducer(form, action);
  console.log(label.padEnd(26), visibleErrors(form));
};

step("types 'ana@'", { type: "change", field: "email", value: "ana@" });
step("leaves the email field", { type: "blur", field: "email" });
step("fixes the email", { type: "change", field: "email", value: "[email protected]" });
step("presses Create account", { type: "submit" });
step("fills in the name", { type: "change", field: "name", value: "Ana" });
step("fills in the password", { type: "change", field: "password", value: "correct-horse" });

Output

types 'ana@'               (no errors shown)
leaves the email field     email: Enter a valid email
fixes the email            (no errors shown)
presses Create account     name: Enter your name | password: Use at least 8 characters
fills in the name          password: Use at least 8 characters
fills in the password      (no errors shown)

Common mistakes

  • Showing errors on the first keystroke. It feels like scolding; wait for blur or submit.
  • Wrong keyboard and autocapitalisation on email fields ("[email protected]").
  • No password manager hints. textContentType (iOS) and autoComplete (Android) let users fill saved credentials.
  • The keyboard hides the submit button. Use KeyboardAvoidingView (or a keyboard-aware scroll view).
  • Validation only on the client. The server must validate again; the client check is for user experience.

Key points

  • Controlled TextInputs, state in a reducer, validation in a pure function.
  • Show errors after blur or submit, not while typing.
  • Use the right keyboard, returnKeyType + refs for "Next", and autofill hints.
  • Keep the keyboard from covering actions.

Further reading: TextInput, KeyboardAvoidingView.

Practice questions

When should a form show a field's validation error?

Answer

After the user has finished with the field (on blur) or tried to submit, not on every keystroke while they're still typing. Track 'touched' per field plus a 'submitted' flag.

Name three TextInput props that make an email field feel native.

Answer

keyboardType="email-address", autoCapitalize="none", and autoComplete="email" / textContentType="emailAddress" for autofill. returnKeyType="next" with a ref to focus the next field also helps.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?