Por qué los formularios en React Native son diferentes (y por qué importa)
Si venís del desarrollo web, seguramente hayas usado React Hook Form con inputs HTML estándar y todo funcionaba de maravilla. Llegás a React Native, intentás lo mismo y... sorpresa, nada funciona como esperabas. Los TextInput de React Native no tienen eventos onChange nativos como el DOM, no existe un <form> que envuelva todo, y el manejo del teclado virtual añade una capa de complejidad que en web simplemente no existe.
Honestamente, la primera vez que me enfrenté a esto perdí un par de horas intentando entender por qué register() no hacía nada. Si te pasó lo mismo, no sos el único.
La buena noticia es que en 2026, el ecosistema ha madurado muchísimo. React Hook Form funciona perfecto con React Native cuando sabés cómo conectar las piezas. Zod te da validación con inferencia de tipos en TypeScript. Y hay patrones probados para manejar formularios complejos — multi-step, campos dinámicos, selects personalizados — que vamos a cubrir todos en esta guía.
Al final de este artículo, vas a tener un sistema de formularios robusto, tipado y reutilizable que podés llevar a cualquier proyecto.
Instalación y configuración del stack moderno
Nuestro stack de formularios en 2026 se compone de tres piezas fundamentales:
- React Hook Form v7.54+: Manejo de estado de formularios con rendimiento óptimo — solo re-renderiza los campos que cambian.
- Zod v3.24+: Esquemas de validación con inferencia de tipos en TypeScript. Definís tu validación una vez y obtenés el tipo automáticamente.
- @hookform/resolvers: El puente entre React Hook Form y Zod (o cualquier otra librería de validación que prefieras).
# Con npm
npm install react-hook-form zod @hookform/resolvers
# Con Expo
npx expo install react-hook-form zod @hookform/resolvers
Estas dependencias son JavaScript puro — no necesitás hacer pod install ni rebuild nativo. Funcionan igual en Expo Go, development builds y bare React Native. Eso es un alivio, la verdad.
Tu primer formulario: registro de usuario
Bueno, arranquemos con un ejemplo completo. Un formulario de registro con nombre, email, contraseña y confirmación de contraseña. Primero el esquema de validación con Zod, después el formulario.
Definir el esquema de validación con Zod
// schemas/auth.ts
import { z } from "zod";
export const registerSchema = z
.object({
name: z
.string()
.min(2, "El nombre debe tener al menos 2 caracteres")
.max(50, "El nombre no puede exceder 50 caracteres"),
email: z
.string()
.email("Ingresá un email válido")
.toLowerCase(),
password: z
.string()
.min(8, "La contraseña debe tener al menos 8 caracteres")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Debe incluir mayúscula, minúscula y número"
),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});
// Inferir el tipo automáticamente del esquema
export type RegisterFormData = z.infer<typeof registerSchema>;
// Resultado: { name: string; email: string; password: string; confirmPassword: string }
Lo importante acá: el tipo RegisterFormData se infiere automáticamente del esquema Zod. Si cambiás la validación, el tipo se actualiza solo. Cero duplicación, cero desincronización entre validación y tipos. Esto solo ya justifica usar Zod en mi opinión.
Construir el formulario
// screens/RegisterScreen.tsx
import React from "react";
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
Alert,
} from "react-native";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema, type RegisterFormData } from "../schemas/auth";
export default function RegisterScreen() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = async (data: RegisterFormData) => {
// data ya está validado y tipado
try {
const response = await fetch("https://api.example.com/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: data.name,
email: data.email,
password: data.password,
}),
});
if (!response.ok) throw new Error("Error en el registro");
Alert.alert("Éxito", "Cuenta creada correctamente");
} catch (error) {
Alert.alert("Error", "No se pudo completar el registro");
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Crear cuenta</Text>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Nombre</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="Tu nombre completo"
autoCapitalize="words"
autoComplete="name"
/>
{errors.name && (
<Text style={styles.errorText}>{errors.name.message}</Text>
)}
</View>
)}
/>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="[email protected]"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
{errors.email && (
<Text style={styles.errorText}>{errors.email.message}</Text>
)}
</View>
)}
/>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={[styles.input, errors.password && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="Mínimo 8 caracteres"
secureTextEntry
autoComplete="new-password"
/>
{errors.password && (
<Text style={styles.errorText}>{errors.password.message}</Text>
)}
</View>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Confirmar contraseña</Text>
<TextInput
style={[
styles.input,
errors.confirmPassword && styles.inputError,
]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="Repetí tu contraseña"
secureTextEntry
autoComplete="new-password"
/>
{errors.confirmPassword && (
<Text style={styles.errorText}>
{errors.confirmPassword.message}
</Text>
)}
</View>
)}
/>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? "Registrando..." : "Crear cuenta"}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
scrollContent: { padding: 24, paddingTop: 60 },
title: { fontSize: 28, fontWeight: "700", marginBottom: 32, color: "#1a1a1a" },
fieldContainer: { marginBottom: 20 },
label: { fontSize: 15, fontWeight: "600", marginBottom: 6, color: "#374151" },
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 12,
padding: 14,
fontSize: 16,
backgroundColor: "#f9fafb",
color: "#1a1a1a",
},
inputError: { borderColor: "#ef4444", backgroundColor: "#fef2f2" },
errorText: { color: "#ef4444", fontSize: 13, marginTop: 4 },
button: {
backgroundColor: "#6366f1",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginTop: 8,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
});
Hay varias decisiones importantes en este código. Repasémoslas:
Controlleres el componente que conecta React Hook Form con TextInput de React Native. En web podés usarregister()directamente, pero en React Native necesitás Controller porque los TextInput no son elementos del DOM.keyboardShouldPersistTaps="handled"evita que al tocar fuera del input el ScrollView cierre el teclado antes de procesar el toque en el botón. Sin esto, el usuario tendría que presionar el botón dos veces (y sí, es tan molesto como suena).KeyboardAvoidingViewasegura que el formulario sea visible cuando el teclado está abierto. En iOS usamosbehavior="padding", en Android"height".autoCompleteen cada input permite que el sistema operativo sugiera datos guardados — autocompletar email, contraseñas, etc.
Componentes de formulario reutilizables
Escribir Controller para cada campo se vuelve repetitivo bastante rápido. Y cuando digo rápido, me refiero al segundo formulario que hagas. La solución es crear componentes reutilizables que encapsulen toda esa lógica.
FormInput: el input universal
// components/form/FormInput.tsx
import React from "react";
import { View, Text, TextInput, StyleSheet, type TextInputProps } from "react-native";
import { Controller, useFormContext, type FieldPath, type FieldValues } from "react-hook-form";
type FormInputProps<T extends FieldValues> = {
name: FieldPath<T>;
label: string;
placeholder?: string;
} & Omit<TextInputProps, "value" | "onChangeText" | "onBlur">;
export function FormInput<T extends FieldValues>({
name,
label,
placeholder,
...textInputProps
}: FormInputProps<T>) {
const {
control,
formState: { errors },
} = useFormContext<T>();
// Acceder al error de forma segura con path anidados
const error = name
.split(".")
.reduce((obj: any, key) => obj?.[key], errors);
return (
<View style={styles.container}>
<Text style={styles.label}>{label}</Text>
<Controller
control={control}
name={name}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, error && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
{...textInputProps}
/>
)}
/>
{error && (
<Text style={styles.errorText}>
{(error as any).message as string}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { marginBottom: 20 },
label: { fontSize: 15, fontWeight: "600", marginBottom: 6, color: "#374151" },
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 12,
padding: 14,
fontSize: 16,
backgroundColor: "#f9fafb",
color: "#1a1a1a",
},
inputError: { borderColor: "#ef4444", backgroundColor: "#fef2f2" },
errorText: { color: "#ef4444", fontSize: 13, marginTop: 4 },
});
La clave acá es useFormContext: este hook accede al formulario desde cualquier nivel de profundidad en el árbol de componentes, sin necesidad de pasar props manualmente. Para que funcione, el formulario tiene que estar envuelto en un FormProvider.
FormProvider wrapper
// components/form/Form.tsx
import React from "react";
import { FormProvider, type UseFormReturn, type FieldValues } from "react-hook-form";
type FormProps<T extends FieldValues> = {
form: UseFormReturn<T>;
children: React.ReactNode;
};
export function Form<T extends FieldValues>({ form, children }: FormProps<T>) {
return <FormProvider {...form}>{children}</FormProvider>;
}
El formulario con componentes reutilizables
Ahora mirá cómo queda el mismo formulario de registro:
// screens/RegisterScreen.tsx (versión simplificada)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form } from "../components/form/Form";
import { FormInput } from "../components/form/FormInput";
import { registerSchema, type RegisterFormData } from "../schemas/auth";
export default function RegisterScreen() {
const form = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
});
const onSubmit = async (data: RegisterFormData) => {
// tu lógica de registro
};
return (
<Form form={form}>
<FormInput<RegisterFormData>
name="name"
label="Nombre"
placeholder="Tu nombre completo"
autoCapitalize="words"
autoComplete="name"
/>
<FormInput<RegisterFormData>
name="email"
label="Email"
placeholder="[email protected]"
keyboardType="email-address"
autoCapitalize="none"
/>
<FormInput<RegisterFormData>
name="password"
label="Contraseña"
placeholder="Mínimo 8 caracteres"
secureTextEntry
/>
<FormInput<RegisterFormData>
name="confirmPassword"
label="Confirmar contraseña"
placeholder="Repetí tu contraseña"
secureTextEntry
/>
<SubmitButton
title="Crear cuenta"
onPress={form.handleSubmit(onSubmit)}
loading={form.formState.isSubmitting}
/>
</Form>
);
}
De más de 100 líneas de JSX pasamos a un formulario limpio, declarativo y completamente tipado. Cada FormInput sabe cómo conectarse al formulario, manejar errores y mostrar validación. La diferencia cuando tenés cinco o seis formularios en tu app es enorme.
Esquemas Zod avanzados para casos reales
Los formularios de registro son el caso más simple. En apps reales vas a necesitar patrones más sofisticados, así que veamos los que uso con más frecuencia.
Campos condicionales
// Formulario de perfil donde el campo "empresa" es obligatorio
// solo si el usuario selecciona tipo "profesional"
const profileSchema = z.discriminatedUnion("accountType", [
z.object({
accountType: z.literal("personal"),
name: z.string().min(2),
bio: z.string().max(280).optional(),
}),
z.object({
accountType: z.literal("professional"),
name: z.string().min(2),
bio: z.string().max(280).optional(),
company: z.string().min(1, "La empresa es obligatoria para cuentas profesionales"),
position: z.string().min(1, "El cargo es obligatorio"),
}),
]);
discriminatedUnion es perfecto para esto. Zod sabe qué campos validar según el valor de accountType, y TypeScript te da el tipo correcto en cada rama. Sin ifs manuales, sin lógica condicional dispersa por el código.
Transformaciones y sanitización
const contactSchema = z.object({
phone: z
.string()
.transform((val) => val.replace(/[\s\-\(\)]/g, "")) // Eliminar espacios y guiones
.pipe(
z.string().regex(/^\+?\d{10,15}$/, "Número de teléfono inválido")
),
website: z
.string()
.url("URL inválida")
.optional()
.or(z.literal("")), // Permitir campo vacío
amount: z
.string()
.transform((val) => parseFloat(val.replace(",", ".")))
.pipe(z.number().positive("El monto debe ser positivo")),
});
El patrón .transform().pipe() es súper útil en React Native porque los TextInput siempre devuelven strings, pero a veces necesitás validar como número. Transform convierte el string antes de que la siguiente validación se ejecute. Es uno de esos patrones que una vez que lo descubrís, lo usás en todos lados.
Arrays y campos dinámicos
const recipeSchema = z.object({
title: z.string().min(3),
ingredients: z
.array(
z.object({
name: z.string().min(1, "Nombre requerido"),
quantity: z.string().min(1, "Cantidad requerida"),
})
)
.min(1, "Agregá al menos un ingrediente")
.max(50, "Máximo 50 ingredientes"),
});
Formularios multi-step con useForm
Los formularios largos — onboarding, checkout, configuración de perfil — funcionan mucho mejor divididos en pasos. A nadie le gusta ver veinte campos de golpe en la pantalla del celular.
React Hook Form maneja esto bastante bien porque mantiene el estado completo del formulario mientras vos controlás qué campos se muestran en cada momento.
// schemas/onboarding.ts
import { z } from "zod";
const step1Schema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
const step2Schema = z.object({
phone: z.string().min(10),
address: z.string().min(5),
});
const step3Schema = z.object({
preferences: z.array(z.string()).min(1, "Seleccioná al menos una preferencia"),
});
// Esquema completo combinando los tres pasos
export const onboardingSchema = step1Schema.merge(step2Schema).merge(step3Schema);
export type OnboardingData = z.infer<typeof onboardingSchema>;
// Exportar schemas individuales para validación por paso
export const stepSchemas = [step1Schema, step2Schema, step3Schema];
// screens/OnboardingScreen.tsx
import React, { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
onboardingSchema,
stepSchemas,
type OnboardingData,
} from "../schemas/onboarding";
import { Form } from "../components/form/Form";
import { FormInput } from "../components/form/FormInput";
const TOTAL_STEPS = 3;
export default function OnboardingScreen() {
const [currentStep, setCurrentStep] = useState(0);
const form = useForm<OnboardingData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
name: "",
email: "",
phone: "",
address: "",
preferences: [],
},
mode: "onTouched", // Validar cuando el campo pierde foco
});
const nextStep = async () => {
// Validar solo los campos del paso actual
const fieldsToValidate = Object.keys(
stepSchemas[currentStep].shape
) as (keyof OnboardingData)[];
const isValid = await form.trigger(fieldsToValidate);
if (isValid && currentStep < TOTAL_STEPS - 1) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) setCurrentStep((prev) => prev - 1);
};
const onSubmit = async (data: OnboardingData) => {
console.log("Datos completos:", data);
};
return (
<Form form={form}>
<View style={styles.container}>
{/* Indicador de progreso */}
<View style={styles.progressBar}>
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
<View
key={i}
style={[
styles.progressDot,
i <= currentStep && styles.progressDotActive,
]}
/>
))}
</View>
{/* Paso 1 */}
{currentStep === 0 && (
<View>
<Text style={styles.stepTitle}>Información básica</Text>
<FormInput<OnboardingData> name="name" label="Nombre" />
<FormInput<OnboardingData> name="email" label="Email" keyboardType="email-address" />
</View>
)}
{/* Paso 2 */}
{currentStep === 1 && (
<View>
<Text style={styles.stepTitle}>Contacto</Text>
<FormInput<OnboardingData> name="phone" label="Teléfono" keyboardType="phone-pad" />
<FormInput<OnboardingData> name="address" label="Dirección" />
</View>
)}
{/* Paso 3: Preferencias (componente personalizado) */}
{currentStep === 2 && (
<View>
<Text style={styles.stepTitle}>Tus preferencias</Text>
{/* Aquí usarías un componente de selección múltiple */}
</View>
)}
{/* Navegación */}
<View style={styles.navRow}>
{currentStep > 0 && (
<Pressable style={styles.backButton} onPress={prevStep}>
<Text style={styles.backButtonText}>Anterior</Text>
</Pressable>
)}
{currentStep < TOTAL_STEPS - 1 ? (
<Pressable style={styles.nextButton} onPress={nextStep}>
<Text style={styles.nextButtonText}>Siguiente</Text>
</Pressable>
) : (
<Pressable
style={styles.nextButton}
onPress={form.handleSubmit(onSubmit)}
>
<Text style={styles.nextButtonText}>Finalizar</Text>
</Pressable>
)}
</View>
</View>
</Form>
);
}
El truco clave es form.trigger(fieldsToValidate): valida solo los campos del paso actual sin tocar los demás. El formulario mantiene todos los datos en memoria, así que cuando el usuario vuelve a un paso anterior, todo sigue ahí intacto.
Manejo del teclado: el detalle que nadie te cuenta
El manejo del teclado es probablemente el aspecto más subestimado de los formularios en React Native. Un formulario puede funcionar perfecto en lógica y validación, pero si el teclado tapa los inputs o el usuario no puede navegar entre campos con fluidez, la experiencia se siente amateur.
Es uno de esos detalles que separan una app que se siente bien de una que se siente profesional.
Navegación entre campos con returnKeyType
import { useRef } from "react";
import { TextInput } from "react-native";
function LoginForm() {
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
return (
<>
<TextInput
placeholder="Email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
/>
<TextInput
ref={passwordRef}
placeholder="Contraseña"
returnKeyType="done"
secureTextEntry
onSubmitEditing={handleSubmit}
/>
</>
);
}
returnKeyType="next" cambia el botón del teclado de "return" a "siguiente". Y blurOnSubmit={false} evita que el campo pierda el foco al presionar "next" — sin esto el teclado parpadea al cambiar de campo, que es bastante molesto.
KeyboardAvoidingView vs react-native-keyboard-controller
KeyboardAvoidingView funciona bien para formularios simples, pero para formularios largos dentro de ScrollView, la librería react-native-keyboard-controller ofrece una experiencia notablemente mejor:
npm install react-native-keyboard-controller
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
function FormScreen() {
return (
<KeyboardAwareScrollView
bottomOffset={20}
style={{ flex: 1 }}
>
{/* Tus campos de formulario */}
{/* El scroll se ajusta automáticamente al campo enfocado */}
</KeyboardAwareScrollView>
);
}
Esta librería usa Reanimated internamente para animaciones fluidas del scroll cuando el teclado aparece, y se integra mucho mejor con la Nueva Arquitectura de React Native que las alternativas más antiguas. Si tu app tiene formularios largos, vale la pena el cambio.
Rendimiento: evitar re-renders innecesarios
React Hook Form ya es eficiente por diseño — usa refs internamente y solo re-renderiza los campos que cambian. Pero hay patrones que pueden arruinar ese rendimiento sin que te des cuenta.
Lo que NO debés hacer
// MAL: watch() causa re-render de todo el componente en cada tecla
function BadForm() {
const { watch, control } = useForm();
const allValues = watch(); // Re-render en CADA cambio
return (
<View>
<Controller name="email" control={control} render={...} />
<Controller name="password" control={control} render={...} />
<Text>Email actual: {allValues.email}</Text>
</View>
);
}
Lo que SÍ debés hacer
// BIEN: useWatch() aislado en un sub-componente
function EmailPreview() {
const email = useWatch({ name: "email" });
return <Text>Email actual: {email}</Text>;
}
function GoodForm() {
const { control } = useForm();
return (
<View>
<Controller name="email" control={control} render={...} />
<Controller name="password" control={control} render={...} />
<EmailPreview /> {/* Solo este componente se re-renderiza */}
</View>
);
}
La diferencia parece sutil, pero en formularios con muchos campos el impacto es real. Algunos consejos más sobre rendimiento:
- Usá
mode: "onBlur"o"onTouched"en lugar de"onChange"para evitar validaciones en cada tecla presionada. - Si el formulario tiene más de 8-10 campos, extraé cada campo a su propio componente memoizado.
- Usá
useWatchen lugar dewatchcuando necesités reaccionar a cambios —useWatchaísla los re-renders al componente que lo usa. - No pongas
formStatecomo dependencia deuseEffect— usá valores específicos comoformState.isDirty.
Integración con librerías de UI populares
Si usás una librería de componentes como Tamagui, NativeWind o React Native Paper, la integración con React Hook Form sigue el mismo patrón. Acá va un ejemplo con React Native Paper para que veas lo simple que es:
import { TextInput as PaperInput, HelperText } from "react-native-paper";
import { Controller, useFormContext } from "react-hook-form";
function PaperFormInput({ name, label, ...props }) {
const { control, formState: { errors } } = useFormContext();
const error = errors[name];
return (
<Controller
control={control}
name={name}
render={({ field: { onChange, onBlur, value } }) => (
<View>
<PaperInput
label={label}
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={!!error}
mode="outlined"
{...props}
/>
<HelperText type="error" visible={!!error}>
{error?.message}
</HelperText>
</View>
)}
/>
);
}
El patrón siempre es el mismo: Controller como puente, onChange conectado a onChangeText, y value controlando el estado. Una vez que internalizás este patrón, podés adaptar cualquier componente de UI a React Hook Form sin mayor problema.
Patrones de validación comunes en apps móviles
Para cerrar la parte práctica, acá van esquemas Zod listos para copiar y adaptar a tus propias apps. Son los que más uso en proyectos reales.
Formulario de dirección
const addressSchema = z.object({
street: z.string().min(5, "Ingresá la calle y número"),
apartment: z.string().optional(),
city: z.string().min(2, "Ingresá la ciudad"),
state: z.string().min(2, "Seleccioná la provincia/estado"),
zipCode: z.string().regex(/^\d{4,10}$/, "Código postal inválido"),
country: z.string().min(2, "Seleccioná el país"),
});
Formulario de pago
const paymentSchema = z.object({
cardNumber: z
.string()
.transform((val) => val.replace(/\s/g, ""))
.pipe(z.string().regex(/^\d{13,19}$/, "Número de tarjeta inválido")),
expiryDate: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, "Formato MM/AA")
.refine((val) => {
const [month, year] = val.split("/").map(Number);
const expiry = new Date(2000 + year, month);
return expiry > new Date();
}, "La tarjeta está vencida"),
cvv: z.string().regex(/^\d{3,4}$/, "CVV inválido"),
holderName: z.string().min(3, "Ingresá el nombre del titular"),
});
Formulario de búsqueda con filtros
const searchFiltersSchema = z.object({
query: z.string().optional(),
minPrice: z
.string()
.optional()
.transform((val) => (val ? parseFloat(val) : undefined))
.pipe(z.number().positive().optional()),
maxPrice: z
.string()
.optional()
.transform((val) => (val ? parseFloat(val) : undefined))
.pipe(z.number().positive().optional()),
category: z.enum(["electronics", "clothing", "food", "other"]).optional(),
sortBy: z.enum(["price_asc", "price_desc", "newest", "popular"]).default("newest"),
}).refine(
(data) => {
if (data.minPrice && data.maxPrice) return data.maxPrice >= data.minPrice;
return true;
},
{ message: "El precio máximo debe ser mayor al mínimo", path: ["maxPrice"] }
);
Preguntas frecuentes
¿Puedo usar Formik en lugar de React Hook Form en React Native?
Sí, Formik funciona con React Native, pero en 2026 React Hook Form es la opción más recomendada. Formik re-renderiza todo el formulario en cada cambio de cualquier campo, lo que impacta notablemente el rendimiento en formularios grandes. React Hook Form usa refs internamente y solo actualiza los campos que cambian. Además, la integración con Zod mediante @hookform/resolvers hace que la validación tipada sea mucho más limpia que con Yup (el validador tradicional de Formik).
¿Cómo manejar selects y pickers con React Hook Form?
Los componentes de selección (como @react-native-picker/picker o bottom sheets personalizados) se conectan igual que TextInput, usando Controller. La diferencia es que en vez de conectar onChangeText, conectás onValueChange al onChange del Controller. Para checkboxes y switches, conectás el prop value y onValueChange del mismo modo.
¿Cómo manejar uploads de imágenes en formularios?
Para imágenes, guardá la URI local (o base64) como valor del campo en React Hook Form. Usá expo-image-picker para capturar la imagen y setValue("avatar", imageUri) para actualizar el formulario. En la validación con Zod, validá que sea un string con formato URI. El upload real al servidor se hace en el onSubmit usando FormData multipart.
¿React Hook Form funciona con Expo Go o necesita desarrollo nativo?
React Hook Form, Zod y @hookform/resolvers son dependencias JavaScript puras — funcionan perfectamente en Expo Go sin necesidad de crear un development build. Solo necesitarías un development build si usás componentes nativos de terceros para inputs personalizados (como pickers nativos o date pickers), pero eso es independiente de la librería de formularios.
¿Cómo persisto los datos del formulario si el usuario cierra la app?
Podés combinar React Hook Form con MMKV o AsyncStorage para guardar borradores automáticamente. Usá watch() con un debounce para detectar cambios y guardarlos en storage. Al montar el formulario, cargá los datos guardados como defaultValues. La librería react-hook-form-persist automatiza este patrón, aunque para React Native necesitás configurar un storage adapter compatible con MMKV.