Perché la Validazione dei Form è Cruciale nelle App Mobile
I form sono dappertutto: login, registrazione, checkout, profilo, ricerca. In pratica, ogni interazione utente in un'app mobile passa da qualche tipo di form. E gestire male la validazione degli input non è solo un problema di dati sbagliati — si riflette direttamente sull'esperienza utente, sulle performance e sulla sicurezza.
Onestamente, dopo aver provato diverse combinazioni di librerie nel corso degli anni, lo stack che nel 2026 funziona meglio per i form in React Native è composto da tre pezzi: React Hook Form (v7.72+), Zod 4 e TypeScript. Insieme ti danno validazione a runtime, type-safety a compile-time, re-render minimi e un'API ergonomica che scala dal form di login più banale fino a wizard multi-step piuttosto complessi.
In questa guida costruiremo form reali, passo dopo passo, con codice che puoi copiare direttamente nel tuo progetto. Niente teoria astratta — solo roba che funziona.
Lo Stack Tecnologico: React Hook Form + Zod 4 + TypeScript
React Hook Form 7.72+
React Hook Form è la libreria di gestione form più popolare nell'ecosistema React, con oltre 42.000 stelle su GitHub. Il suo punto di forza? L'architettura basata su componenti non controllati (uncontrolled components), che minimizza i re-render. Mentre Formik causa un re-render dell'intero form ad ogni keystroke, React Hook Form aggiorna solo il campo che stai modificando.
Per React Native, la libreria mette a disposizione il componente Controller, che fa da ponte tra la sua API e i componenti nativi come TextInput. È un dettaglio che sembra piccolo, ma fa una differenza enorme quando lavori con form complessi.
Zod 4: Validazione con Steroids
Zod 4 è arrivato dopo un anno di sviluppo attivo e i numeri parlano da soli: parsing delle stringhe 14 volte più veloce, array 7 volte e oggetti 6.5 volte. Il bundle è 2.3 volte più piccolo, e i tempi di compilazione TypeScript migliorano fino a 10 volte.
Ma la caratteristica che cambia tutto è la capacità di generare tipi TypeScript direttamente dagli schema con z.infer. Scrivi uno schema Zod e hai automaticamente sia la validazione a runtime che la type-safety a compile-time. Un'unica definizione, zero duplicazione. È una di quelle cose che una volta provata non torni più indietro.
Perché Questo Stack e Non Formik + Yup?
Formik è stato il punto di riferimento per anni, e va dato atto che ha aperto la strada. Ma presenta problemi reali in React Native: re-render eccessivi, API verbosa, e lo sviluppo ha rallentato parecchio. Yup resta valido, però la sua API è meno intuitiva di Zod e l'integrazione con TypeScript non è allo stesso livello.
Nel 2026, React Hook Form + Zod è semplicemente lo standard de facto per le nuove app React Native.
Installazione e Configurazione
Iniziamo con l'installazione — niente di complicato, sono tre pacchetti:
npm install react-hook-form zod @hookform/resolvers
Se usi Expo (che è quello che consiglio nella maggior parte dei casi), non servono configurazioni aggiuntive. Per un progetto bare React Native, la configurazione è identica.
Verifica le versioni nel tuo package.json:
{
"react-hook-form": "^7.72.0",
"zod": "^4.3.0",
"@hookform/resolvers": "^5.0.0"
}
Nota se stai aggiornando da Zod 3: le format validation come .email() sono ora disponibili anche come funzioni top-level sul modulo z. La personalizzazione degli errori usa un parametro unificato error al posto delle API frammentate di prima. Un miglioramento che si apprezza subito.
Il Primo Form: Login con Email e Password
Ok, partiamo con le mani in pasta. Costruiamo un form di login completo: prima lo schema Zod, poi il componente React Native.
Step 1: Definire lo Schema di Validazione
import { z } from "zod";
const loginSchema = z.object({
email: z
.string({ error: "L'email è obbligatoria" })
.email({ message: "Inserisci un indirizzo email valido" }),
password: z
.string({ error: "La password è obbligatoria" })
.min(8, { message: "La password deve avere almeno 8 caratteri" }),
});
// Il tipo TypeScript viene generato automaticamente dallo schema
type LoginFormData = z.infer;
// Risultato: { email: string; password: string }
Lo schema loginSchema definisce due campi, punto. Zod li valida a runtime, TypeScript usa il tipo inferito LoginFormData per il controllo statico. Una definizione, zero duplicazione.
Step 2: Costruire il Componente Form
import React from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const LoginForm: React.FC = () => {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (data: LoginFormData) => {
// data è già validato e tipizzato
console.log("Login con:", data.email);
// await loginAPI(data);
};
return (
Accedi
(
Email
{errors.email && (
{errors.email.message}
)}
)}
/>
(
Password
{errors.password && (
{errors.password.message}
)}
)}
/>
{isSubmitting ? "Accesso in corso..." : "Accedi"}
);
};
Ecco cosa succede nel codice:
zodResolver(loginSchema)collega lo schema Zod a React Hook Form — è il "ponte" tra le due librerieControlleravvolge ogniTextInpute gestisce il binding dei dati in automaticoerrorsviene popolato da Zod con i messaggi che abbiamo definito nello schemaKeyboardAvoidingViewevita che la tastiera copra i campi — su mobile è essenziale, fidati- Le props
autoCompleteetextContentTypeattivano l'autocompletamento nativo del sistema operativo
Componente Input Riutilizzabile
Ripetere il pattern Controller + TextInput + messaggio di errore per ogni campo diventa verboso in fretta. Dopo il terzo campo ti viene voglia di astrarre il tutto — e infatti è la cosa giusta da fare.
import React from "react";
import { View, Text, TextInput, TextInputProps, StyleSheet } from "react-native";
import {
Controller,
Control,
FieldValues,
Path,
FieldErrors,
} from "react-hook-form";
interface FormInputProps extends TextInputProps {
control: Control;
name: Path;
label: string;
errors: FieldErrors;
}
function FormInput({
control,
name,
label,
errors,
...textInputProps
}: FormInputProps) {
const error = errors[name];
return (
{label}
(
)}
/>
{error && (
{error.message as string}
)}
);
}
E adesso il form di login diventa molto più snello:
Il componente è completamente tipizzato grazie ai generics di TypeScript. Se provi a usare un name che non esiste nello schema, il compilatore te lo segnala subito. Niente più errori a runtime per un typo nel nome del campo.
Schema Zod 4 Avanzati per React Native
Zod può fare molto di più delle semplici validazioni su stringhe. Vediamo i pattern che tornano utili più spesso nelle app mobile.
Gestione dei Numeri da TextInput
C'è un dettaglio che colpisce chi inizia: in React Native, TextInput restituisce sempre una stringa, anche con keyboardType="numeric". La soluzione è z.coerce, che converte automaticamente:
const productSchema = z.object({
nome: z.string().min(1, { message: "Il nome è obbligatorio" }),
prezzo: z.coerce
.number({ error: "Inserisci un numero valido" })
.positive({ message: "Il prezzo deve essere positivo" })
.multipleOf(0.01, { message: "Massimo due decimali" }),
quantita: z.coerce
.number({ error: "Inserisci un numero valido" })
.int({ message: "La quantità deve essere un numero intero" })
.min(1, { message: "Minimo 1 pezzo" }),
});
Validazione Condizionale con discriminatedUnion
Uno scenario che capita spesso: hai un form di spedizione dove i campi cambiano in base al metodo di consegna. Con discriminatedUnion si gestisce in modo pulito:
const spedizioneSchema = z.discriminatedUnion("metodoConsegna", [
z.object({
metodoConsegna: z.literal("domicilio"),
indirizzo: z.string().min(5, { message: "Indirizzo obbligatorio" }),
citta: z.string().min(2, { message: "Città obbligatoria" }),
cap: z.string().regex(/^\d{5}$/, { message: "CAP non valido (5 cifre)" }),
}),
z.object({
metodoConsegna: z.literal("ritiro"),
puntoRitiro: z.string().min(1, { message: "Seleziona un punto di ritiro" }),
}),
]);
Validazione Cross-Field con refine
Per validare relazioni tra campi — tipo la classica conferma password — usi refine o superRefine:
const registrationSchema = z
.object({
nome: z.string().min(2, { message: "Il nome deve avere almeno 2 caratteri" }),
email: z.string().email({ message: "Email non valida" }),
password: z
.string()
.min(8, { message: "Minimo 8 caratteri" })
.regex(/[A-Z]/, { message: "Deve contenere almeno una maiuscola" })
.regex(/[0-9]/, { message: "Deve contenere almeno un numero" })
.regex(/[^A-Za-z0-9]/, { message: "Deve contenere almeno un carattere speciale" }),
confermaPassword: z.string(),
accettaTermini: z.literal(true, {
error: "Devi accettare i termini e le condizioni",
}),
})
.refine((data) => data.password === data.confermaPassword, {
message: "Le password non coincidono",
path: ["confermaPassword"],
});
type RegistrationFormData = z.infer;
Transform per Pulizia dei Dati
Con transform puoi normalizzare i dati prima che arrivi la validazione finale. È comodissimo per quei campi dove l'utente può inserire formati diversi:
const contattoSchema = z.object({
email: z
.string()
.email()
.transform((val) => val.toLowerCase().trim()),
telefono: z
.string()
.transform((val) => val.replace(/\s+/g, ""))
.pipe(
z.string().regex(/^\+?\d{10,13}$/, {
message: "Numero di telefono non valido",
})
),
});
Form Multi-Step: Wizard di Registrazione
I form multi-step sono praticamente d'obbligo nelle app mobile. Lo schermo è piccolo, e un form con 15 campi tutti insieme scoraggia chiunque. Molto meglio dividere il tutto in step logici.
Ecco come implementare un wizard di registrazione a 3 step, con validazione indipendente per ogni passaggio.
Definire gli Schema per Ogni Step
const step1Schema = z.object({
nome: z.string().min(2, { message: "Il nome deve avere almeno 2 caratteri" }),
cognome: z.string().min(2, { message: "Il cognome deve avere almeno 2 caratteri" }),
dataNascita: z.string().regex(/^\d{2}\/\d{2}\/\d{4}$/, {
message: "Formato data: GG/MM/AAAA",
}),
});
const step2Schema = z.object({
email: z.string().email({ message: "Email non valida" }),
telefono: z.string().min(10, { message: "Numero di telefono non valido" }),
});
const step3Schema = z
.object({
password: z
.string()
.min(8, { message: "Minimo 8 caratteri" })
.regex(/[A-Z]/, { message: "Serve almeno una maiuscola" }),
confermaPassword: z.string(),
})
.refine((data) => data.password === data.confermaPassword, {
message: "Le password non coincidono",
path: ["confermaPassword"],
});
// Schema completo per il tipo finale
const fullRegistrationSchema = step1Schema.merge(step2Schema).and(step3Schema);
type FullRegistrationData = z.infer;
Il Componente Wizard
import React, { useState } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schemas = [step1Schema, step2Schema, step3Schema];
const stepTitles = ["Dati Personali", "Contatti", "Sicurezza"];
const RegistrationWizard: React.FC = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState>({});
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schemas[currentStep]),
defaultValues: formData,
});
const onNext = (data: Record) => {
const updatedData = { ...formData, ...data };
setFormData(updatedData);
if (currentStep < schemas.length - 1) {
setCurrentStep((prev) => prev + 1);
} else {
// Ultimo step: invia tutto
handleFinalSubmit(updatedData as FullRegistrationData);
}
};
const onBack = () => {
if (currentStep > 0) setCurrentStep((prev) => prev - 1);
};
const handleFinalSubmit = async (data: FullRegistrationData) => {
console.log("Registrazione completata:", data);
// await registerAPI(data);
};
return (
{/* Indicatore progresso */}
{stepTitles.map((title, index) => (
{index + 1}
))}
{stepTitles[currentStep]}
{/* Qui renderizza i campi in base a currentStep */}
{/* ... campi del form ... */}
{currentStep > 0 && (
Indietro
)}
{currentStep === schemas.length - 1 ? "Registrati" : "Avanti"}
);
};
L'idea è semplice: ogni step ha il proprio schema Zod e la propria validazione. I dati si accumulano in formData e vengono inviati solo al completamento dell'ultimo step. In questo modo l'utente non può andare avanti senza aver compilato correttamente il passaggio corrente — e non si sente sopraffatto da un muro di campi.
UX dei Form su Mobile: Best Practice
La validazione è solo metà del lavoro. L'esperienza utente è quello che fa la differenza tra un form che converte e uno che l'utente abbandona dopo 5 secondi.
Quando Mostrare gli Errori
React Hook Form supporta diverse strategie tramite l'opzione mode:
const { control, handleSubmit } = useForm({
resolver: zodResolver(loginSchema),
mode: "onBlur", // Valida quando il campo perde il focus
// mode: "onChange", // Valida ad ogni modifica (più aggressivo)
// mode: "onSubmit", // Valida solo al submit (default)
// mode: "onTouched", // Valida al primo blur, poi ad ogni modifica
});
Per le app mobile, onBlur è quasi sempre la scelta giusta: mostra gli errori quando l'utente lascia un campo, senza interromperlo mentre sta ancora scrivendo. onTouched è un buon compromesso — diventa più reattivo dopo la prima interazione con il campo.
Gestione della Tastiera
Su mobile la tastiera occupa circa metà schermo (a volte anche di più). È un problema serio che va gestito fin da subito:
import { KeyboardAvoidingView, Platform, ScrollView } from "react-native";
// Avvolgi il form per evitare che la tastiera copra i campi
{/* Campi del form */}
La prop keyboardShouldPersistTaps="handled" è un dettaglio che fa la differenza: permette di toccare i pulsanti senza che la tastiera si chiuda prima, evitando quel fastidiosissimo doppio tap che disorienta gli utenti.
Navigazione tra Campi con returnKeyType
Un tocco di professionalità: permetti la navigazione campo-per-campo con il tasto "Avanti" della tastiera, usando i ref.
import { useRef } from "react";
const passwordRef = useRef(null);
// Sul campo email:
passwordRef.current?.focus()}
/>
// Sul campo password:
Accessibilità
Le proprietà di accessibilità non sono opzionali — sono essenziali per chi usa screen reader, e comunque migliorano la qualità complessiva dell'app:
{errors.email && (
{errors.email.message}
)}
Integrare la Validazione Server-Side
La validazione lato client è necessaria ma non sufficiente. Il server deve sempre rivalidare i dati — mai fidarsi solo del client. Ma come mostrare gli errori che arrivano dal server direttamente nei campi del form? React Hook Form ha il metodo setError che risolve esattamente questo problema:
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
const response = await fetch("https://api.esempio.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
// Errori specifici per campo dal server
if (errorData.field === "email") {
setError("email", {
type: "server",
message: errorData.message, // es. "Email già registrata"
});
}
// Errore generico (non associato a un campo)
if (errorData.type === "generic") {
setError("root", {
type: "server",
message: errorData.message,
});
}
return;
}
// Login riuscito
const userData = await response.json();
// navigateToHome(userData);
} catch {
setError("root", {
type: "network",
message: "Errore di connessione. Riprova.",
});
}
};
// Nel JSX, mostra l'errore generico:
{errors.root && (
{errors.root.message}
)}
La chiave root è pensata apposta per gli errori non legati a un campo specifico — perfetta per errori di rete, credenziali sbagliate o problemi del server in generale.
Ottimizzazione delle Performance
React Hook Form è già ottimizzato di suo per minimizzare i re-render. Ma nei form complessi con tanti campi, ci sono un paio di tecniche in più che possono fare la differenza.
FormStateSubscribe per Re-render Mirati
Il componente FormStateSubscribe (introdotto nella v7.72) ti permette di sottoscriverti ai cambiamenti di stato di un singolo campo, senza far fare re-render all'intero form:
import { FormStateSubscribe } from "react-hook-form";
// Solo questo componente fa re-render quando cambia lo stato di "email"
(
{isDirty && Campo modificato }
{error && {error.message} }
)}
/>
Evitare Re-render con useWatch
Hai bisogno di osservare il valore di un campo, magari per mostrare un'anteprima in tempo reale? Usa useWatch al posto di watch — si isola nel suo sotto-albero di rendering e non causa re-render inutili al resto del form:
import { useWatch } from "react-hook-form";
const PasswordStrength: React.FC<{ control: Control }> = ({
control,
}) => {
const password = useWatch({ control, name: "password" });
const strength = calculatePasswordStrength(password || "");
return (
Sicurezza: {strength > 70 ? "Forte" : strength > 40 ? "Media" : "Debole"}
);
};
Zod Mini per Bundle Ridotti
Se la dimensione del bundle ti preoccupa (e su mobile dovrebbe), Zod 4 offre @zod/mini: una distribuzione che pesa solo circa 1.9 KB gzippati. È tree-shakable e pensata per le app dove ogni kilobyte conta.
// Invece di:
import { z } from "zod";
// Usa:
import { z } from "@zod/mini";
L'API è compatibile per la maggior parte dei casi d'uso comuni. Se il tuo form non ha bisogno di feature avanzate come discriminatedUnion, vale la pena considerarla.
Esempio Completo: Form di Checkout
Chiudiamo con un esempio più corposo che mette insieme le tecniche viste finora: un form di checkout con validazione del numero di carta, gestione della tastiera e pulizia automatica dei dati.
import { z } from "zod";
const checkoutSchema = z.object({
nomeCompleto: z
.string()
.min(3, { message: "Inserisci il nome completo" }),
numeroCarta: z
.string()
.transform((val) => val.replace(/\s+/g, ""))
.pipe(
z.string()
.regex(/^\d{16}$/, { message: "Il numero di carta deve avere 16 cifre" })
),
scadenza: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, { message: "Formato: MM/AA" }),
cvv: z
.string()
.regex(/^\d{3,4}$/, { message: "CVV non valido" }),
indirizzo: z
.string()
.min(5, { message: "Indirizzo obbligatorio" }),
citta: z
.string()
.min(2, { message: "Città obbligatoria" }),
cap: z
.string()
.regex(/^\d{5}$/, { message: "CAP non valido (5 cifre)" }),
});
type CheckoutData = z.infer;
Questo schema mostra un pattern molto utile: la combinazione di transform e pipe. Prima rimuove gli spazi dal numero di carta, poi valida il risultato pulito. L'utente può tranquillamente digitare "1234 5678 9012 3456" e Zod si occupa della normalizzazione in automatico. Un piccolo dettaglio che migliora tantissimo l'esperienza di compilazione.
Domande Frequenti (FAQ)
React Hook Form funziona con Expo?
Assolutamente sì. React Hook Form è una libreria JavaScript pura, senza dipendenze da moduli nativi. Installi react-hook-form, zod e @hookform/resolvers con npm o yarn e sei subito operativo in un progetto Expo.
Qual è la differenza tra Formik e React Hook Form per React Native?
In una parola: performance. React Hook Form usa componenti non controllati e aggiorna solo i campi modificati, mentre Formik re-renderizza tutto il form ad ogni cambio. Con form semplici (3-4 campi) non te ne accorgi, ma con form da 10+ campi la differenza è tangibile. Aggiungi un'API più concisa e una comunità più attiva, e nel 2026 la scelta è piuttosto chiara.
Come gestisco i campi dinamici (array di campi) con React Hook Form?
L'hook useFieldArray ti permette di aggiungere, rimuovere e riordinare campi in modo performante. Combinato con uno schema Zod che usa z.array(), ottieni validazione e tipizzazione completa degli array di oggetti. È utilissimo per cose come liste di indirizzi, elementi del carrello o questionari con domande variabili.
Posso usare Zod 4 con React Hook Form senza TypeScript?
Sì, Zod fornisce validazione a runtime a prescindere da TypeScript. Puoi definire gli schema e usarli con zodResolver anche in JavaScript puro. Detto questo, TypeScript è caldamente consigliato: la combinazione Zod + TypeScript elimina la duplicazione tra tipi e regole di validazione, e il codice diventa molto più facile da mantenere.
Come gestisco la validazione asincrona, tipo verificare se un'email esiste già?
Zod supporta validazione asincrona con refine e funzioni async. Però per le app mobile il consiglio è separare le cose: usa lo schema Zod per le regole istantanee (formato, lunghezza, pattern) e gestisci i controlli server-side nel callback onSubmit, usando setError di React Hook Form per mostrare eventuali errori del server nei campi giusti. È un approccio che funziona meglio sia per le performance che per l'esperienza utente.