Validation
Validate when a field loses focus, show one message at a time, and block the submit button while work is in flight.
<TextInput onBlur={() => setError(validate(value))} />Validation is a question of timing as much as rules. Checking on every keystroke means the user sees "not a valid email" after typing the first letter. That reads as an accusation and it is wrong: they have not finished.
Validate on blur
Run the check when a field loses focus. TextInput gives you onBlur, which fires when focus leaves. Keep a per-field flag, often called touched, and show a message only once that field has been touched. After the first failed submit it is reasonable to switch that field to live checking, so the message clears as the user fixes it.
One message
A field has one error line, directly beneath it, in plain words. Not a list, not a red border with no explanation. Say what to do: "Enter your email address" beats "Invalid input". Keep the layout stable so the form does not jump when the message appears; reserving a minimum height for the error row is enough.
Guard the submit
Validate everything again on submit, because a user can press the button without ever focusing a field. Then set a flag while the submit is running, and use it for both disabled on the button and the label. disabled on a Pressable actually stops onPress firing, so this is a real guard against a double submit, not just a grey look.
Shorthand and longhand
{error !== null && <Text>{error}</Text>} and {error !== null ? <Text>{error}</Text> : null} render the same thing. Prefer the ternary: && on a value that could be 0 or "" renders that value instead of nothing.
Example
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
type SubscribeFormProps = {
onSubmit: (email: string) => Promise<void>;
};
function validateEmail(text: string): string | null {
const trimmed = text.trim();
if (trimmed === "") return "Enter your email address";
if (!trimmed.includes("@")) return "An email address needs an @";
return null;
}
// On screen: one field with a message line under it. The message appears when
// you leave the field empty or malformed, and the button reads "Sending" and
// stops responding while the submit is running.
export default function SubscribeForm({ onSubmit }: SubscribeFormProps) {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const [sending, setSending] = useState(false);
async function handlePress() {
const message = validateEmail(email); // always re-check on submit
setTouched(true);
setError(message);
if (message !== null) return;
setSending(true);
try {
await onSubmit(email.trim());
setEmail("");
} finally {
setSending(false); // finally, so a rejection does not freeze the button
}
}
return (
<View style={styles.container}>
<TextInput
style={styles.input}
value={email}
onChangeText={(text) => {
setEmail(text);
if (touched) setError(validateEmail(text)); // live only after first blur
}}
onBlur={() => {
setTouched(true);
setError(validateEmail(email));
}}
placeholder="[email protected]"
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.error}>{error ?? ""}</Text>
<Pressable style={styles.button} disabled={sending} onPress={handlePress}>
<Text style={styles.buttonText}>{sending ? "Sending" : "Subscribe"}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 4 },
input: { borderWidth: 1, borderColor: "#c6c6c8", borderRadius: 8, padding: 10 },
error: { minHeight: 18, color: "#d70015", fontSize: 12 }, // reserved so nothing jumps
button: { marginTop: 8, backgroundColor: "#0a84ff", borderRadius: 8, padding: 14, alignItems: "center" },
buttonText: { color: "#fff", fontWeight: "600" },
});When to use it
- Telling the user their email is malformed when they move to the password field, not while they type it.
- Keeping a sign-up button inert until the required fields pass.
- Preventing a double charge when an impatient user taps Submit twice.
- Clearing an error message as soon as the user corrects the field that caused it.
More examples
Rules as pure functions
Rules live outside the component as functions of a string, so they are trivial to unit test and reusable across fields. firstError returning on the first failure is what keeps the UI to one message. This is the shape a schema library gives you later, written by hand.
export type Rule = (value: string) => string | null;
export const required = (label: string): Rule => (value) =>
value.trim() === "" ? `${label} is required` : null;
export const minLength = (n: number): Rule => (value) =>
value.length < n ? `Must be at least ${n} characters` : null;
// Returns the FIRST failure, so the user only ever sees one message.
export function firstError(value: string, rules: Rule[]): string | null {
for (const rule of rules) {
const message = rule(value);
if (message !== null) return message;
}
return null;
}
const passwordRules = [required("Password"), minLength(8)];
console.log(firstError("", passwordRules));
// Output: Password is required
console.log(firstError("short", passwordRules));
// Output: Must be at least 8 characters
console.log(firstError("longenough", passwordRules));
// Output: nullTwo fields, one touched map
Two fields, each silent until it has been left once. Errors are computed from values on every render rather than stored, so they can never disagree with the input. field() is a plain function that returns JSX, not a component, so it holds no hooks of its own and the hook order stays fixed.
import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";
type Values = { name: string; email: string };
type Field = keyof Values;
function validate(values: Values): Partial<Record<Field, string>> {
const errors: Partial<Record<Field, string>> = {};
if (values.name.trim().length < 2) errors.name = "Enter your full name";
if (!values.email.includes("@")) errors.email = "An email address needs an @";
return errors;
}
export default function ProfileFields() {
const [values, setValues] = useState<Values>({ name: "", email: "" });
const [touched, setTouched] = useState<Partial<Record<Field, boolean>>>({});
const errors = validate(values); // derived every render, never stored in state
function field(name: Field, placeholder: string) {
const message = touched[name] === true ? errors[name] : undefined;
return (
<View style={styles.field}>
<TextInput
style={styles.input}
value={values[name]}
placeholder={placeholder}
autoCapitalize={name === "email" ? "none" : "words"}
onChangeText={(text) => setValues({ ...values, [name]: text })}
onBlur={() => setTouched({ ...touched, [name]: true })}
/>
<Text style={styles.error}>{message ?? ""}</Text>
</View>
);
}
return (
<View style={styles.container}>
{field("name", "Full name")}
{field("email", "Email")}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8 },
field: { gap: 2 },
input: { borderWidth: 1, borderColor: "#c6c6c8", borderRadius: 8, padding: 10 },
error: { minHeight: 16, color: "#d70015", fontSize: 12 },
});A submit that cannot fire twice
The ref is the part people miss: state updates are asynchronous, so two fast taps can both see busy as false. A ref changes immediately. Use this button for anything that must happen once, such as placing an order or sending an invite.
import { useRef, useState } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
type SubmitButtonProps = {
label: string;
onSubmit: () => Promise<void>;
};
export default function SubmitButton({ label, onSubmit }: SubmitButtonProps) {
const [busy, setBusy] = useState(false);
const inFlight = useRef(false); // read synchronously, unlike state
async function handlePress() {
if (inFlight.current) return; // two taps in the same frame: second one leaves here
inFlight.current = true;
setBusy(true);
try {
await onSubmit();
} finally {
inFlight.current = false;
setBusy(false);
}
}
return (
<Pressable
style={({ pressed }) => [styles.button, (busy || pressed) && styles.dim]}
disabled={busy}
onPress={handlePress}
accessibilityState={{ disabled: busy, busy }}
>
<Text style={styles.text}>{busy ? "Working" : label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: { backgroundColor: "#0a84ff", borderRadius: 8, padding: 14, alignItems: "center" },
dim: { opacity: 0.6 },
text: { color: "#fff", fontWeight: "600" },
});
Discussion