Objetivo del capítulo
En este capítulo construirás un flujo real de registro/edición de perfil con formularios robustos: validación sincrónica y asíncrona, mensajes de error útiles, estados de envío, prevención de doble submit, manejo de foco y teclado, y consideraciones de accesibilidad. La meta es que el formulario se sienta “profesional”: rápido, claro, tolerante a errores y consistente.
Caso real: Registro / Edición de perfil
El formulario tendrá estos campos típicos:
- Nombre (requerido, mínimo 2 caracteres)
- Email (requerido, formato válido, y verificación asíncrona de disponibilidad)
- Teléfono (opcional, formato E.164 o validación simple por país)
- Contraseña (requerida en registro; opcional en edición, con reglas de seguridad)
- Confirmación de contraseña (debe coincidir)
Además, integraremos una API para:
GET /me(precargar datos en edición)POST /auth/register(registro)PATCH /me(actualización de perfil)GET /auth/check-email?email=...(validación asíncrona)
Arquitectura de formulario: estado, errores y envío
Un formulario profesional suele separar claramente:
- values: valores actuales
- touched: si el usuario ya interactuó con el campo (para no mostrar errores “demasiado pronto”)
- errors: errores por campo (y opcionalmente globales)
- isSubmitting: bloqueo de UI y prevención de doble submit
- async validation state: por ejemplo, “verificando email…”
En React Native puedes implementarlo con estado local y funciones puras de validación. Si prefieres librerías, react-hook-form + zod/yup es una combinación común; aquí lo haremos con una implementación explícita para entender bien el flujo y poder reutilizarlo.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Modelo de datos
type ProfileFormValues = { name: string; email: string; phone: string; password: string; passwordConfirm: string;};type FieldName = keyof ProfileFormValues;type FieldErrors = Partial<Record<FieldName, string>>;Validación sincrónica: reglas claras y mensajes útiles
La validación sincrónica debe ser:
- Determinista: misma entrada, mismo resultado
- Rápida: se ejecuta en cada cambio o blur
- Con mensajes accionables: “Incluye al menos 8 caracteres” es mejor que “Contraseña inválida”
Funciones de validación (email, teléfono, contraseña)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const e164Regex = /^\+?[1-9]\d{7,14}$/; // simple, no perfecto por paísfunction validatePassword(pw: string) { const errors: string[] = []; if (pw.length < 8) errors.push('Mínimo 8 caracteres'); if (!/[A-Z]/.test(pw)) errors.push('Incluye una mayúscula'); if (!/[a-z]/.test(pw)) errors.push('Incluye una minúscula'); if (!/\d/.test(pw)) errors.push('Incluye un número'); return errors;}function validateSync(values: ProfileFormValues, mode: 'register' | 'edit'): FieldErrors { const errors: FieldErrors = {}; if (!values.name.trim()) errors.name = 'Escribe tu nombre'; else if (values.name.trim().length < 2) errors.name = 'Debe tener al menos 2 caracteres'; if (!values.email.trim()) errors.email = 'Escribe tu email'; else if (!emailRegex.test(values.email.trim())) errors.email = 'Formato de email inválido'; if (values.phone.trim() && !e164Regex.test(values.phone.trim())) { errors.phone = 'Usa un teléfono válido (ej: +34911222333)'; } if (mode === 'register') { if (!values.password) errors.password = 'Crea una contraseña'; else { const pwErrors = validatePassword(values.password); if (pwErrors.length) errors.password = pwErrors.join('. '); } if (!values.passwordConfirm) errors.passwordConfirm = 'Confirma tu contraseña'; else if (values.passwordConfirm !== values.password) { errors.passwordConfirm = 'Las contraseñas no coinciden'; } } else { // En edición, password opcional: si se escribe, se valida y se exige confirmación if (values.password) { const pwErrors = validatePassword(values.password); if (pwErrors.length) errors.password = pwErrors.join('. '); if (!values.passwordConfirm) errors.passwordConfirm = 'Confirma tu contraseña'; else if (values.passwordConfirm !== values.password) { errors.passwordConfirm = 'Las contraseñas no coinciden'; } } } return errors;}Cuándo mostrar errores: touched + blur
Una buena UX evita “castigar” al usuario mientras escribe. Patrón recomendado:
- Marca un campo como
touchedenonBlur - Muestra el error si
touched[field]y existeerrors[field] - En submit, marca todos como touched para revelar lo pendiente
Validación asíncrona: disponibilidad de email (sin saturar la API)
La validación asíncrona típica es comprobar si un email ya está registrado. Requisitos de UX:
- Debounce para no llamar a la API en cada tecla
- Cancelación o control de “respuesta más reciente” para evitar condiciones de carrera
- Estados visibles: “Verificando…” y error si no está disponible
Hook: debounce simple
import { useEffect, useState } from 'react';function useDebouncedValue<T>(value: T, delayMs: number) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(id); }, [value, delayMs]); return debounced;}Validación asíncrona con control de carrera
type EmailCheckState = { status: 'idle' | 'checking' | 'available' | 'taken' | 'error'; message?: string;};async function checkEmailAvailability(email: string): Promise<{ available: boolean }> { const res = await fetch(`https://api.tuapp.com/auth/check-email?email=${encodeURIComponent(email)}`); if (!res.ok) throw new Error('Error verificando email'); return res.json();}function useEmailAvailability(email: string, enabled: boolean) { const [state, setState] = useState<EmailCheckState>({ status: 'idle' }); const debouncedEmail = useDebouncedValue(email.trim(), 400); useEffect(() => { let cancelled = false; const run = async () => { if (!enabled) { setState({ status: 'idle' }); return; } if (!debouncedEmail) { setState({ status: 'idle' }); return; } // Evita chequear si el formato es inválido if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail)) { setState({ status: 'idle' }); return; } setState({ status: 'checking' }); try { const result = await checkEmailAvailability(debouncedEmail); if (cancelled) return; setState(result.available ? { status: 'available' } : { status: 'taken', message: 'Este email ya está en uso' }); } catch (e) { if (cancelled) return; setState({ status: 'error', message: 'No se pudo verificar el email' }); } }; run(); return () => { cancelled = true; }; }, [debouncedEmail, enabled]); return state;}Integración recomendada: solo habilitar el check si el usuario ya tocó el campo email o si está en modo registro.
Componentes reutilizables: Input, Helper y Botón de acción
Para consistencia visual y de comportamiento, define componentes de formulario reutilizables. Deben soportar:
- Etiqueta, placeholder
- Estado de error y mensaje
- Accesibilidad (roles, labels, hints)
- Icono/indicador opcional (por ejemplo, “verificando email”)
- Props para teclado:
keyboardType,textContentType,autoComplete,secureTextEntry,returnKeyType
FormTextField (controlado)
import React from 'react';import { View, Text, TextInput, ActivityIndicator } from 'react-native';type FormTextFieldProps = { label: string; value: string; onChangeText: (t: string) => void; onBlur?: () => void; placeholder?: string; error?: string; helperText?: string; keyboardType?: 'default' | 'email-address' | 'phone-pad'; secureTextEntry?: boolean; returnKeyType?: 'next' | 'done'; onSubmitEditing?: () => void; inputRef?: React.RefObject<TextInput>; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; textContentType?: any; autoComplete?: any; rightStatus?: 'idle' | 'loading' | 'success' | 'error'; accessibilityHint?: string;};export function FormTextField(props: FormTextFieldProps) { const { label, value, onChangeText, onBlur, placeholder, error, helperText, keyboardType='default', secureTextEntry, returnKeyType, onSubmitEditing, inputRef, autoCapitalize='none', rightStatus='idle', accessibilityHint } = props; const describedBy = error ? `${label}-error` : helperText ? `${label}-helper` : undefined; return ( <View style={{ marginBottom: 14 }}> <Text style={{ fontWeight: '600', marginBottom: 6 }}>{label}</Text> <View style={{ flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: error ? '#D33' : '#CCC', borderRadius: 10, paddingHorizontal: 12 }}> <TextInput ref={inputRef} value={value} onChangeText={onChangeText} onBlur={onBlur} placeholder={placeholder} keyboardType={keyboardType} secureTextEntry={secureTextEntry} returnKeyType={returnKeyType} onSubmitEditing={onSubmitEditing} autoCapitalize={autoCapitalize} accessibilityLabel={label} accessibilityHint={accessibilityHint} accessibilityState={{ invalid: !!error }} accessibilityDescribedBy={describedBy as any} style={{ flex: 1, paddingVertical: 12 }} /> {rightStatus === 'loading' ? <ActivityIndicator /> : null} </View> {error ? ( <Text nativeID={`${label}-error`} style={{ color: '#D33', marginTop: 6 }}>{error}</Text> ) : helperText ? ( <Text nativeID={`${label}-helper`} style={{ color: '#666', marginTop: 6 }}>{helperText}</Text> ) : null} </View> );}Botón de acción con estado de envío
import React from 'react';import { Pressable, Text, ActivityIndicator } from 'react-native';type SubmitButtonProps = { title: string; onPress: () => void; disabled?: boolean; loading?: boolean;};export function SubmitButton({ title, onPress, disabled, loading }: SubmitButtonProps) { const isDisabled = disabled || loading; return ( <Pressable onPress={onPress} disabled={isDisabled} accessibilityRole="button" accessibilityState={{ disabled: isDisabled, busy: !!loading }} style={{ backgroundColor: isDisabled ? '#999' : '#111', paddingVertical: 14, borderRadius: 12, alignItems: 'center' }} > {loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontWeight: '700' }}>{title}</Text>} </Pressable> );}Manejo de foco y “Next/Done” entre campos
En móvil, el flujo de teclado es parte central de la UX. Objetivos:
- Al presionar “Next”, enfocar el siguiente campo
- Al presionar “Done”, intentar enviar
- Evitar que el teclado tape el campo activo
Refs y onSubmitEditing
import React, { useMemo, useRef, useState } from 'react';import { View, TextInput, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';export function ProfileFormScreen({ mode }: { mode: 'register' | 'edit' }) { const nameRef = useRef<TextInput>(null); const emailRef = useRef<TextInput>(null); const phoneRef = useRef<TextInput>(null); const passwordRef = useRef<TextInput>(null); const confirmRef = useRef<TextInput>(null); const [values, setValues] = useState({ name: '', email: '', phone: '', password: '', passwordConfirm: '' }); const [touched, setTouched] = useState<Partial<Record<keyof typeof values, boolean>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof typeof values, string>>>({}); const [isSubmitting, setIsSubmitting] = useState(false); const emailCheck = useEmailAvailability(values.email, !!touched.email && mode === 'register'); const setField = (field: keyof typeof values, v: string) => { setValues(prev => ({ ...prev, [field]: v })); }; const blurField = (field: keyof typeof values) => { setTouched(prev => ({ ...prev, [field]: true })); const nextErrors = validateSync(values, mode); setErrors(nextErrors); }; const submit = async () => { // Marca todo como touched para mostrar errores setTouched({ name: true, email: true, phone: true, password: true, passwordConfirm: true }); const nextErrors = validateSync(values, mode); // Integra error asíncrono de email si aplica if (mode === 'register' && emailCheck.status === 'taken') { nextErrors.email = emailCheck.message || 'Email no disponible'; } setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) return; if (isSubmitting) return; // prevención doble submit setIsSubmitting(true); try { if (mode === 'register') { await fetch('https://api.tuapp.com/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: values.name.trim(), email: values.email.trim().toLowerCase(), phone: values.phone.trim() || null, password: values.password }) }).then(r => { if (!r.ok) throw new Error('Registro fallido'); }); } else { await fetch('https://api.tuapp.com/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: values.name.trim(), phone: values.phone.trim() || null, ...(values.password ? { password: values.password } : {}) }) }).then(r => { if (!r.ok) throw new Error('Actualización fallida'); }); } } catch (e: any) { // Error global: podrías mapear errores del backend a campos específicos // Ejemplo: si backend devuelve { field: 'email', message: '...' } setErrors(prev => ({ ...prev, email: prev.email || 'Revisa tus datos e inténtalo de nuevo' })); } finally { setIsSubmitting(false); } }; const emailRightStatus = useMemo(() => { if (mode !== 'register') return 'idle'; if (!touched.email) return 'idle'; if (emailCheck.status === 'checking') return 'loading'; if (emailCheck.status === 'available') return 'success'; if (emailCheck.status === 'taken') return 'error'; return 'idle'; }, [emailCheck.status, mode, touched.email]); return ( <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}> <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16 }}> <FormTextField label="Nombre" value={values.name} onChangeText={(t) => setField('name', t)} onBlur={() => blurField('name')} placeholder="Tu nombre" error={touched.name ? errors.name : undefined} returnKeyType="next" onSubmitEditing={() => emailRef.current?.focus()} inputRef={nameRef} autoCapitalize="words" textContentType="name" /> <FormTextField label="Email" value={values.email} onChangeText={(t) => setField('email', t)} onBlur={() => blurField('email')} placeholder="tu@email.com" keyboardType="email-address" error={touched.email ? (errors.email || (emailCheck.status === 'taken' ? emailCheck.message : undefined)) : undefined} helperText={mode === 'register' && touched.email && emailCheck.status === 'checking' ? 'Verificando disponibilidad...' : undefined} rightStatus={emailRightStatus as any} returnKeyType="next" onSubmitEditing={() => phoneRef.current?.focus()} inputRef={emailRef} autoCapitalize="none" textContentType="emailAddress" /> <FormTextField label="Teléfono" value={values.phone} onChangeText={(t) => setField('phone', t)} onBlur={() => blurField('phone')} placeholder="+34911222333" keyboardType="phone-pad" error={touched.phone ? errors.phone : undefined} returnKeyType={mode === 'register' ? 'next' : 'done'} onSubmitEditing={() => { if (mode === 'register') passwordRef.current?.focus(); else submit(); }} inputRef={phoneRef} autoCapitalize="none" textContentType="telephoneNumber" /> {mode === 'register' ? ( <> <FormTextField label="Contraseña" value={values.password} onChangeText={(t) => setField('password', t)} onBlur={() => blurField('password')} placeholder="Crea una contraseña" secureTextEntry error={touched.password ? errors.password : undefined} returnKeyType="next" onSubmitEditing={() => confirmRef.current?.focus()} inputRef={passwordRef} autoCapitalize="none" textContentType="newPassword" accessibilityHint="Debe tener al menos 8 caracteres e incluir mayúsculas, minúsculas y números" /> <FormTextField label="Confirmar contraseña" value={values.passwordConfirm} onChangeText={(t) => setField('passwordConfirm', t)} onBlur={() => blurField('passwordConfirm')} placeholder="Repite la contraseña" secureTextEntry error={touched.passwordConfirm ? errors.passwordConfirm : undefined} returnKeyType="done" onSubmitEditing={submit} inputRef={confirmRef} autoCapitalize="none" textContentType="newPassword" /> </> ) : null} <View style={{ height: 8 }} /> <SubmitButton title={mode === 'register' ? 'Crear cuenta' : 'Guardar cambios'} onPress={submit} loading={isSubmitting} disabled={mode === 'register' && emailCheck.status === 'checking'} /> </ScrollView> </KeyboardAvoidingView> );}Prevención de doble submit y estados de envío
Problemas típicos:
- El usuario toca el botón varias veces
- El teclado sigue abierto y el usuario no ve el estado
- La pantalla permite editar mientras se envía, generando inconsistencias
Medidas recomendadas:
- Usar
isSubmittingpara deshabilitar el botón y bloquear reintentos - Mostrar indicador de carga en el botón
- Opcional: deshabilitar inputs durante el envío
- Opcional: cerrar teclado al enviar (
Keyboard.dismiss())
import { Keyboard } from 'react-native';const submit = async () => { Keyboard.dismiss(); if (isSubmitting) return; setIsSubmitting(true); try { // ... request } finally { setIsSubmitting(false); }};Mensajes de error: campo vs global, y mapeo desde backend
Un formulario real recibe errores del servidor. Buenas prácticas:
- Errores de campo: se muestran debajo del input (ej: “Email ya registrado”)
- Error global: para fallos generales (ej: “Sin conexión”, “Inténtalo más tarde”)
- No duplicar: si el backend ya valida, úsalo para reforzar, pero mantén validación local para feedback inmediato
Ejemplo de mapeo de error de API a campos
type ApiError = { code: string; message: string; field?: FieldName };function mapApiErrorToForm(err: ApiError): { fieldErrors?: FieldErrors; globalError?: string } { if (err.field) { return { fieldErrors: { [err.field]: err.message } as FieldErrors }; } if (err.code === 'NETWORK') return { globalError: 'Revisa tu conexión e inténtalo de nuevo' }; return { globalError: err.message || 'Ocurrió un error inesperado' };}En UI, el error global puede mostrarse en un bloque arriba del formulario con accessibilityRole="alert" para lectores de pantalla.
Teclado, scroll y campos tapados
En pantallas con varios campos, el teclado puede tapar el input activo. Soluciones:
KeyboardAvoidingView(especialmente iOS)ScrollViewconkeyboardShouldPersistTaps="handled"- Si el formulario es largo, considera librerías como
react-native-keyboard-aware-scroll-view(opcional) o implementar scroll al foco
Patrón mínimo recomendado
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}> <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16 }}> {/* campos */} </ScrollView></KeyboardAvoidingView>Accesibilidad (a11y) aplicada a formularios
Checklist práctico:
- Cada input debe tener accessibilityLabel (la etiqueta visible suele bastar si la asignas)
- Si hay error, marcar
accessibilityState={{ invalid: true }} - Los mensajes de error deben ser anunciables (por ejemplo, con
accessibilityRole="alert"en un contenedor o usandonativeID+accessibilityDescribedBycuando aplique) - Botón de envío con
accessibilityState.busycuando está cargando - Orden lógico de foco (alineado con “Next”)
Error anunciable (patrón simple)
{error ? ( <Text accessibilityRole="alert" style={{ color: '#D33' }}>{error}</Text>) : null}Formatos y normalización de datos antes de enviar
Además de validar, conviene normalizar:
- Email:
trim()ytoLowerCase() - Nombre:
trim()(y opcionalmente colapsar espacios) - Teléfono: remover espacios/guiones si tu backend espera E.164
function normalizePhone(input: string) { return input.replace(/[\s-]/g, '');}const payload = { name: values.name.trim(), email: values.email.trim().toLowerCase(), phone: values.phone ? normalizePhone(values.phone) : null};Guía paso a paso para implementar el formulario completo
Paso 1: Define valores, touched, errores e isSubmitting
- Crea el estado
valuescon todos los campos - Crea
touchedpara controlar cuándo mostrar errores - Crea
errorspara almacenar mensajes por campo - Crea
isSubmittingpara bloquear el envío
Paso 2: Implementa validateSync(values, mode)
- Reglas de requerido y mínimos
- Regex para email
- Validación de teléfono (simple o E.164)
- Reglas de contraseña y confirmación
Paso 3: Conecta inputs reutilizables
- Usa
FormTextFieldpara todos los campos - En
onBlur, marcatouched[field]=truey recalcula errores - Muestra
errorsolo si el campo está touched
Paso 4: Agrega validación asíncrona del email
- Implementa
useDebouncedValue - Implementa
useEmailAvailabilitycon estadochecking/taken/available - Integra el resultado en el error del campo email
- Deshabilita el submit mientras
checkingsi lo consideras necesario
Paso 5: Maneja foco y teclado
- Crea refs por input
- Configura
returnKeyTypeyonSubmitEditingpara avanzar - Envuelve en
KeyboardAvoidingView+ScrollView
Paso 6: Implementa submit con prevención de doble envío
- En submit: marca todos touched
- Calcula errores sync y añade errores async relevantes
- Si hay errores, no envíes
- Si
isSubmittinges true, retorna - Deshabilita botón y muestra loading
Paso 7: Integra API y mapea errores del backend
- En registro:
POST /auth/register - En edición:
PATCH /me - Si backend devuelve error por campo, colócalo en
errors[field] - Si es global, muéstralo arriba del formulario
Tabla de decisiones UX recomendadas
| Situación | Recomendación | Motivo |
|---|---|---|
| Error de formato mientras escribe | Mostrar tras onBlur o tras un pequeño delay | Evita frustración y ruido visual |
| Email requiere verificación | Debounce + indicador “verificando” | Reduce llamadas y aumenta confianza |
| Submit | Deshabilitar botón + loading | Previene doble submit |
| Errores del servidor | Mapear a campo si aplica | Corrección directa y rápida |
| Teclado tapa inputs | KeyboardAvoidingView + scroll | Evita que el usuario “adivine” |
| Accesibilidad | accessibilityState.invalid + alert en error | Mejora experiencia con lectores de pantalla |