Diseño de formularios robustos en Ionic
En Ionic, los formularios suelen construirse con ion-item + ion-input, ion-select, ion-datetime, ion-textarea y controles móviles como ion-toggle o ion-checkbox. Para que un formulario sea “robusto”, no basta con capturar datos: debe guiar al usuario (UX), validar correctamente (sincronía y asincronía), evitar errores comunes (doble envío) y normalizar la información antes de enviarla.
Campos y tipos adecuados (mejor UX y menos errores)
- Tipo de teclado: usa
typeyinputmodepara mostrar el teclado correcto (email, numérico, etc.). - Autocompletado:
autocompleteyautocapitalizeayudan a rellenar rápido y evitan errores. - Máscaras y formato: cuando el dato requiere un patrón (teléfono, documento), aplica formato en el cliente, pero guarda/enviar una versión normalizada.
- Selección y fechas: para opciones finitas,
ion-select; para fechas/horas,ion-datetime(evita que el usuario escriba formatos ambiguos). - Controles móviles:
ion-togglepara preferencias,ion-checkboxpara aceptación de términos,ion-rangepara valores continuos.
Ejemplos de controles típicos
<ion-list> <ion-item> <ion-label position="stacked">Email</ion-label> <ion-input type="email" inputmode="email" autocomplete="email" autocapitalize="none"></ion-input> </ion-item> <ion-item> <ion-label position="stacked">Contraseña</ion-label> <ion-input type="password" autocomplete="current-password"></ion-input> </ion-item> <ion-item> <ion-label>País</ion-label> <ion-select interface="popover" placeholder="Selecciona"> <ion-select-option value="MX">México</ion-select-option> <ion-select-option value="ES">España</ion-select-option> </ion-select> </ion-item> <ion-item> <ion-label position="stacked">Fecha de nacimiento</ion-label> <ion-datetime presentation="date"></ion-datetime> </ion-item></ion-list>Validación en Ionic con Reactive Forms
Para formularios con reglas claras, validaciones complejas y control fino de estados (touched, dirty, pending), Reactive Forms es una opción sólida. La idea es definir un FormGroup con FormControl, validadores síncronos (inmediatos) y asíncronos (requieren tiempo, por ejemplo, verificar disponibilidad).
1) Preparación del módulo
Asegúrate de importar ReactiveFormsModule en el módulo/página donde se usa el formulario.
import { NgModule } from '@angular/core';import { CommonModule } from '@angular/common';import { IonicModule } from '@ionic/angular';import { ReactiveFormsModule } from '@angular/forms';import { RegisterPage } from './register.page';@NgModule({ declarations: [RegisterPage], imports: [CommonModule, IonicModule, ReactiveFormsModule]})export class RegisterPageModule {}2) Definir el formulario con validaciones síncronas
Ejemplo: registro con nombre, email, teléfono, fecha de nacimiento, contraseña, confirmación y aceptación de términos. Se incluyen validaciones de requerido, formato, longitud y una validación a nivel de grupo para comparar contraseñas.
import { Component } from '@angular/core';import { AbstractControl, FormBuilder, ValidationErrors, Validators } from '@angular/forms';@Component({ selector: 'app-register', templateUrl: './register.page.html'})export class RegisterPage { isSubmitting = false; form = this.fb.group({ fullName: ['', [Validators.required, Validators.minLength(2)]], email: ['', [Validators.required, Validators.email]], phone: ['', [Validators.required, Validators.pattern(/^\+?[0-9]{8,15}$/)]], birthDate: ['', [Validators.required]], password: ['', [Validators.required, Validators.minLength(8)]], confirmPassword: ['', [Validators.required]], acceptTerms: [false, [Validators.requiredTrue]] }, { validators: [this.passwordsMatchValidator] }); constructor(private fb: FormBuilder) {} private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { const p = group.get('password')?.value; const c = group.get('confirmPassword')?.value; if (!p || !c) return null; return p === c ? null : { passwordsMismatch: true }; }}3) Validación asíncrona (ejemplo: email ya registrado)
Una validación asíncrona suele dispararse cuando el usuario deja el campo o tras un pequeño retraso. En Angular puedes configurar updateOn: 'blur' para evitar validar en cada tecla. La validación asíncrona debe devolver null si es válido o un objeto de error si no lo es.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
import { AsyncValidatorFn, AbstractControl, ValidationErrors, FormControl } from '@angular/forms';import { Observable, of, timer } from 'rxjs';import { map, switchMap, catchError } from 'rxjs/operators';// Simulación: reemplaza por tu servicio real que consulte disponibilidadfunction emailAvailableValidator(checkFn: (email: string) => Observable<{ available: boolean }>): AsyncValidatorFn { return (control: AbstractControl): Observable<ValidationErrors | null> => { const email = String(control.value || '').trim().toLowerCase(); if (!email) return of(null); return timer(400).pipe( switchMap(() => checkFn(email)), map(res => res.available ? null : { emailTaken: true }), catchError(() => of(null)) ); };}// Uso en el control (ejemplo con updateOn blur)email = new FormControl('', { validators: [Validators.required, Validators.email], asyncValidators: [emailAvailableValidator((e) => this.fakeCheckEmail(e))], updateOn: 'blur'});Si tu formulario ya está creado con fb.group, puedes integrar el control email anterior o definirlo directamente en el grupo con la configuración equivalente.
Mensajes de error amigables (sin saturar la pantalla)
Una buena UX muestra errores cuando el usuario ya interactuó con el campo (por ejemplo, touched) y con mensajes claros orientados a la acción. Evita mensajes técnicos como “pattern invalid”.
Patrón recomendado para mostrar errores
- Mostrar error si:
control.invalidy (control.touchedo intento de envío). - Un mensaje por vez, priorizando el más útil (requerido > formato > longitud).
- Usar
ion-noteoion-textcon colordanger.
<ion-item> <ion-label position="stacked">Email</ion-label> <ion-input formControlName="email" type="email" inputmode="email" autocapitalize="none"></ion-input></ion-item><ion-note color="danger" *ngIf="showError('email')"> {{ emailErrorMessage }}</ion-note>get emailCtrl() { return this.form.get('email'); }submitted = false;showError(name: string): boolean { const c = this.form.get(name); return !!c && c.invalid && (c.touched || this.submitted);}get emailErrorMessage(): string { const c = this.emailCtrl; if (!c || !c.errors) return ''; if (c.errors['required']) return 'Escribe tu email.'; if (c.errors['email']) return 'Escribe un email válido (ej: nombre@dominio.com).'; if (c.errors['emailTaken']) return 'Este email ya está registrado. Prueba con otro.'; return 'Revisa este campo.';}Tabla rápida: error técnico → mensaje humano
| Error | Mensaje sugerido |
|---|---|
required | “Completa este campo.” |
email | “Escribe un email válido.” |
minlength | “Debe tener al menos X caracteres.” |
pattern | “Usa el formato correcto (ej: …).” |
passwordsMismatch | “Las contraseñas no coinciden.” |
Prevención de envíos duplicados
En móviles es común tocar dos veces el botón o perder conectividad y reintentar. Debes bloquear el envío mientras está en curso y dar feedback visual.
Reglas prácticas
- Deshabilita el botón si el formulario es inválido, está pendiente (
pending) o se está enviando. - Marca
submitted = trueal intentar enviar para mostrar errores sin obligar a tocar cada campo. - Evita múltiples suscripciones simultáneas: usa una bandera
isSubmittingo un operador que ignore reintentos mientras hay uno activo.
<ion-button expand="block" (click)="submit()" [disabled]="form.invalid || form.pending || isSubmitting"> <ion-spinner *ngIf="isSubmitting" name="lines-small"></ion-spinner> <span *ngIf="!isSubmitting">Crear cuenta</span></ion-button>async submit() { this.submitted = true; this.form.markAllAsTouched(); if (this.form.invalid || this.form.pending || this.isSubmitting) return; this.isSubmitting = true; try { const payload = this.normalizeRegisterPayload(this.form.value); // Llama a tu servicio de autenticación/registro // await this.auth.register(payload); } finally { this.isSubmitting = false; }}Normalización de datos antes de enviar
Validar no es lo mismo que normalizar. Normalizar significa transformar a un formato consistente para almacenar o enviar: recortar espacios, unificar mayúsculas/minúsculas, eliminar separadores, convertir fechas a ISO, etc. Esto reduce errores en backend y evita duplicados lógicos (por ejemplo, emails con espacios).
Ejemplo de normalización para registro
normalizeRegisterPayload(raw: any) { const fullName = String(raw.fullName || '').trim().replace(/\s+/g, ' '); const email = String(raw.email || '').trim().toLowerCase(); const phone = String(raw.phone || '').trim().replace(/[\s()-]/g, ''); const birthDateIso = raw.birthDate ? new Date(raw.birthDate).toISOString() : null; return { fullName, email, phone, birthDate: birthDateIso, password: String(raw.password || '') };}Máscaras: mostrar bonito, enviar limpio
Si decides mostrar el teléfono con separadores (por ejemplo, “+34 600 123 123”), mantén el valor interno normalizado. Una estrategia simple es formatear en el evento ionBlur (para no pelear con el cursor) y normalizar al enviar.
<ion-input formControlName="phone" inputmode="tel" (ionBlur)="formatPhone()"></ion-input>formatPhone() { const c = this.form.get('phone'); const raw = String(c?.value || ''); const normalized = raw.trim().replace(/[\s()-]/g, ''); c?.setValue(normalized, { emitEvent: false });}Pautas de seguridad básica en el cliente
El formulario vive en el cliente, por lo que cualquier validación puede ser alterada por un atacante. Aun así, el cliente debe aplicar buenas prácticas para reducir riesgos y evitar filtraciones accidentales.
1) No confiar en la validación del front
- La validación en el cliente es para UX; el servidor debe validar de nuevo (requeridos, permisos, límites, formatos).
- No asumas que un campo “disabled” no se enviará: un atacante puede construir la petición manualmente.
- Evita lógica de autorización en el cliente (por ejemplo, “si rol=admin, mostrar botón” no debe ser la única barrera).
2) Manejo prudente de tokens
- No muestres tokens en UI ni los registres en consola (
console.log). - Evita persistir tokens en lugares inseguros. Si necesitas persistencia, usa almacenamiento seguro del dispositivo (por ejemplo, un plugin de secure storage) en lugar de texto plano.
- Minimiza el tiempo de vida del token y usa refresh tokens si tu arquitectura lo contempla (la política exacta depende del backend).
- En logout, limpia el token y cualquier dato sensible asociado.
3) Errores y mensajes: no filtrar información sensible
- En login, evita mensajes que confirmen si un email existe (“usuario no existe”) si tu política de seguridad lo desaconseja. Un mensaje genérico como “Credenciales inválidas” reduce enumeración.
- En validaciones asíncronas (email disponible), considera si revelar disponibilidad es aceptable para tu caso de uso.
4) Protección básica contra envíos repetidos y automatización
- El bloqueo de doble envío ayuda a evitar duplicados accidentales, pero no detiene bots. La mitigación real se hace en servidor (rate limiting, captchas, etc.).
- En el cliente, agrega retrasos y estados de carga para desalentar taps repetidos.
Ejemplo práctico: formulario de registro con Ionic (paso a paso)
Paso 1: Plantilla con componentes móviles
<form [formGroup]="form" (ngSubmit)="submit()"> <ion-list> <ion-item> <ion-label position="stacked">Nombre completo</ion-label> <ion-input formControlName="fullName" autocomplete="name"></ion-input> </ion-item> <ion-note color="danger" *ngIf="showError('fullName')"> {{ fullNameErrorMessage }} </ion-note> <ion-item> <ion-label position="stacked">Email</ion-label> <ion-input formControlName="email" type="email" inputmode="email" autocomplete="email" autocapitalize="none"></ion-input> </ion-item> <ion-note color="danger" *ngIf="showError('email')"> {{ emailErrorMessage }} </ion-note> <ion-item> <ion-label position="stacked">Teléfono</ion-label> <ion-input formControlName="phone" inputmode="tel" autocomplete="tel" (ionBlur)="formatPhone()"></ion-input> </ion-item> <ion-note color="danger" *ngIf="showError('phone')"> {{ phoneErrorMessage }} </ion-note> <ion-item> <ion-label position="stacked">Fecha de nacimiento</ion-label> <ion-datetime formControlName="birthDate" presentation="date" showDefaultButtons="true"></ion-datetime> </ion-item> <ion-note color="danger" *ngIf="showError('birthDate')"> Especifica tu fecha de nacimiento. </ion-note> <ion-item> <ion-label position="stacked">Contraseña</ion-label> <ion-input formControlName="password" type="password" autocomplete="new-password"></ion-input> </ion-item> <ion-note color="danger" *ngIf="showError('password')"> {{ passwordErrorMessage }} </ion-note> <ion-item> <ion-label position="stacked">Confirmar contraseña</ion-label> <ion-input formControlName="confirmPassword" type="password" autocomplete="new-password"></ion-input> </ion-item> <ion-note color="danger" *ngIf="submitted && form.errors?.passwordsMismatch"> Las contraseñas no coinciden. </ion-note> <ion-item lines="none"> <ion-checkbox slot="start" formControlName="acceptTerms"></ion-checkbox> <ion-label>Acepto los términos y condiciones</ion-label> </ion-item> <ion-note color="danger" *ngIf="showError('acceptTerms')"> Debes aceptar los términos para continuar. </ion-note> </ion-list> <ion-button type="submit" expand="block" [disabled]="form.invalid || form.pending || isSubmitting"> <ion-spinner *ngIf="isSubmitting" name="lines-small"></ion-spinner> <span *ngIf="!isSubmitting">Crear cuenta</span> </ion-button></form>Paso 2: Mensajes de error por campo
get fullNameErrorMessage(): string { const c = this.form.get('fullName'); if (!c?.errors) return ''; if (c.errors['required']) return 'Escribe tu nombre.'; if (c.errors['minlength']) return 'Tu nombre es demasiado corto.'; return 'Revisa tu nombre.';}get phoneErrorMessage(): string { const c = this.form.get('phone'); if (!c?.errors) return ''; if (c.errors['required']) return 'Escribe tu teléfono.'; if (c.errors['pattern']) return 'Usa un teléfono válido (solo dígitos, con prefijo opcional).'; return 'Revisa tu teléfono.';}get passwordErrorMessage(): string { const c = this.form.get('password'); if (!c?.errors) return ''; if (c.errors['required']) return 'Crea una contraseña.'; if (c.errors['minlength']) return 'Usa al menos 8 caracteres.'; return 'Revisa tu contraseña.';}Paso 3: Envío seguro a nivel de cliente (sin filtrar datos)
En el envío, evita loguear el formulario completo (podría incluir contraseñas). Normaliza el payload y, si necesitas depurar, registra solo campos no sensibles.
async submit() { this.submitted = true; this.form.markAllAsTouched(); if (this.form.invalid || this.form.pending || this.isSubmitting) return; this.isSubmitting = true; try { const payload = this.normalizeRegisterPayload(this.form.value); // No hagas console.log(payload) si incluye password // await this.auth.register(payload); } catch (e) { // Muestra un mensaje genérico y registra el detalle solo en un canal seguro si aplica } finally { this.isSubmitting = false; }}Paso 4: Ajustes finos de UX
- Orden de tabulación: en móvil, el botón “siguiente” del teclado debe avanzar de forma natural; revisa el orden de los campos.
- Validar en blur: para campos con validación asíncrona o reglas costosas, usa
updateOn: 'blur'. - Accesibilidad: etiquetas claras, mensajes de error cerca del campo, y evita depender solo del color.
- Estados: muestra
ion-spinnerdurante el envío y deshabilita el formulario si es necesario.