Formularios, validación y experiencia de usuario en React Native

Capítulo 7

Tiempo estimado de lectura: 14 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 touched en onBlur
  • Muestra el error si touched[field] y existe errors[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 isSubmitting para 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)
  • ScrollView con keyboardShouldPersistTaps="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 usando nativeID + accessibilityDescribedBy cuando aplique)
  • Botón de envío con accessibilityState.busy cuando 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() y toLowerCase()
  • 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 values con todos los campos
  • Crea touched para controlar cuándo mostrar errores
  • Crea errors para almacenar mensajes por campo
  • Crea isSubmitting para 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 FormTextField para todos los campos
  • En onBlur, marca touched[field]=true y recalcula errores
  • Muestra error solo si el campo está touched

Paso 4: Agrega validación asíncrona del email

  • Implementa useDebouncedValue
  • Implementa useEmailAvailability con estado checking/taken/available
  • Integra el resultado en el error del campo email
  • Deshabilita el submit mientras checking si lo consideras necesario

Paso 5: Maneja foco y teclado

  • Crea refs por input
  • Configura returnKeyType y onSubmitEditing para 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 isSubmitting es 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ónRecomendaciónMotivo
Error de formato mientras escribeMostrar tras onBlur o tras un pequeño delayEvita frustración y ruido visual
Email requiere verificaciónDebounce + indicador “verificando”Reduce llamadas y aumenta confianza
SubmitDeshabilitar botón + loadingPreviene doble submit
Errores del servidorMapear a campo si aplicaCorrección directa y rápida
Teclado tapa inputsKeyboardAvoidingView + scrollEvita que el usuario “adivine”
AccesibilidadaccessibilityState.invalid + alert en errorMejora experiencia con lectores de pantalla

Ahora responde el ejercicio sobre el contenido:

¿Cuál combinación de medidas mejora la UX al validar de forma asíncrona la disponibilidad del email sin saturar la API?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El enfoque recomendado combina debounce para reducir llamadas, control de carreras para evitar resultados desactualizados y estados visibles (verificando/error) para dar feedback claro al usuario.

Siguiente capítulo

Autenticación, autorización y seguridad aplicada en React Native

Arrow Right Icon
Portada de libro electrónico gratuitaReact Native desde Cero a App Profesional
58%

React Native desde Cero a App Profesional

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.