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.
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
TextInputgets itsvaluefrom state and reports changes withonChangeText. - 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",secureTextEntryfor passwords,returnKeyType="next"with refs to focus the next field,textContentType/autoCompletefor password managers, andKeyboardAvoidingViewso 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.
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) andautoComplete(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.