Parte 6: Reactive Forms App¶
Una aplicación pequeña para aprender formularios reactivos (reactive
forms): la forma de Angular de construir un formulario en el código del
componente, donde cada campo es un objeto que sabe su valor, si es válido y
si el usuario ya lo tocó. La plantilla solo se conecta a ese objeto. Es una
aplicación Angular 22 creada con ng new, standalone y zoneless, igual que
las partes anteriores, pero aquí el aspecto lo da Bootstrap, no Tailwind CSS
ni daisyUI. Viene después de la Parte 5: Pipes App porque usa
el pipe json para mirar el estado del formulario
(Módulo PP6).
Proyecto: 06-reactive-forms-app/. Sitio publicado:
angular-forms.reskyon.com.
Ábrelo mientras lees. Para correrlo en local usa npm start dentro de la
carpeta (http://localhost:4200/reactive/basic).
Esta página va creciendo: cada módulo nuevo se agrega al final. Por ahora cubre
las pantallas Basic (/reactive/basic), Dynamic (/reactive/dynamic),
Switches (/reactive/switches), Registro (/auth/sign-up) y
Country (/country, los selectores anidados).
Qué vas a aprender:
- La base de la pantalla. Instalar Bootstrap, usar su grid y marcar la página activa en un menú lateral (Módulo RF1).
- Crear el formulario. Armar un formulario con
FormGroupyFormControly conectarlo a la plantilla con[formGroup]yformControlName(Módulo RF2), y escribir menos conFormBuilderyValidators(Módulo RF3). - Leer el estado. Mostrar
valid,pristine,touched,valueyerrors, y entender por qué pasar unFormControlcompleto al pipejsonfalla (Módulo RF4). - Mostrar errores y sus límites. Sacar la lógica de los mensajes de la
plantilla con la clase
FormUtils(Módulo RF5), y ver por qué este enfoque se complica cuando el formulario crece (Módulo RF6). - Listas que crecen. Agregar y borrar campos mientras el usuario usa la
página con un
FormArray(Módulo RF7). - Opciones para elegir. Radios, switches y casillas de «acepto los
términos», con
Validators.requiredTrue(Módulo RF8). - Validar entre campos. Patrones con
Validators.pattern, un validador propio que compara dos campos, otro para un solo campo, y por qué el error del primero queda en el grupo y no en el campo (Módulo RF9). - Esperar al servidor. Un validador asíncrono, el estado
PENDINGy por qué comprobar soloinvalidantes de enviar es un riesgo (Módulo RF10). - Listas que dependen de otras. Tres desplegables encadenados con datos de
una API:
valueChanges,switchMap, un traductor (mapper) para la respuesta y varias peticiones juntas concombineLatest(Módulo RF11).
Cada módulo sigue el mismo orden: el problema que resuelve, el código del repositorio, cómo funciona por dentro, errores comunes y preguntas de entrevista con su respuesta.
Esta página es una parte de la guía de estudio; allí están el mapa general y la ruta recomendada. Conviene haber visto antes la Parte 5: Pipes App.
Módulo RF1: Bootstrap y el menú lateral¶
El problema que resuelve¶
Quieres que el formulario se vea ordenado (etiqueta a la izquierda, campo a la derecha) sin escribir CSS. Bootstrap trae eso ya hecho: una cuadrícula (grid) de 12 columnas y clases para formularios, botones y listas. Además necesitas un menú que marque en qué página estás.
Instalar Bootstrap¶
npm install bootstrap
En src/styles.scss se importa todo Bootstrap con una línea:
@use 'bootstrap/scss/bootstrap';
@use es la forma moderna de Sass para cargar otro archivo. Eso incluye el
reboot (un reinicio de estilos del navegador: márgenes de h1, box-sizing,
tipografía), las clases de formularios (form-control), botones (btn) y
listas (list-group).
Si solo quisieras la cuadrícula, la línea sería
@use 'bootstrap/scss/bootstrap-grid'. Eso da container, row, col-*,
los espacios (mt-5, mb-3) y algunas clases de flex, pero no los estilos
de formulario ni de botón. En este proyecto se usa todo, porque la pantalla
necesita formularios y botones.
Bootstrap también tiene JavaScript (modales, menús desplegables). Aquí no se instala: solo se usan sus estilos.
La cuadrícula en app.html¶
<div class="row mt-5">
<div class="col">
<h1>Reactive Forms App</h1>
<router-outlet />
</div>
<div class="col-12 col-sm-4">
<app-side-menu></app-side-menu>
</div>
</div>
rowes una fila; sus hijoscol*se reparten el ancho.colocupa el espacio que sobra.col-12 col-sm-4ocupa las 12 columnas (todo el ancho) en un celular y 4 de 12 desde la pantallasm(576 px).mt-5es un margen superior grande.
Un error que se ve y no avisa¶
Una versión anterior de ese archivo envolvía todo en
<div class="containter"> (con una t de más). Angular no se queja, porque
una clase desconocida es solo texto. El resultado en pantalla:
- la página aparecía pegada al borde izquierdo, con las letras cortadas;
- aparecía una barra de scroll horizontal.
La causa: .row tiene márgenes negativos a los lados y .container es quien
los compensa con su relleno (padding). Sin .container correcto, la fila se
sale de la pantalla. Se arregla escribiendo container bien. Cuando algo de
Bootstrap «no hace nada», revisa primero el nombre de la clase.
El menú lateral¶
SideMenuComponent crea su lista a partir de las rutas, para no escribir cada
enlace a mano:
reactiveMenu: MenuItem[] = reactiveMenuItems
// Exclude wildcard routes and map to MenuItem format. This will prevent invalid routes from appearing in the side menu, such as 'undefined'.
.filter((item: Route) => item.path !== '**')
.map((item: Route) => ({
route: `reactive/${item.path}`,
title: `${item.title}`,
}))
reactiveMenuItems son los children de reactive.routes.ts (el archivo
shared/constants/side-menu.constants.ts los saca de ahí). Se filtra la ruta
comodín '**' porque no tiene title y el menú mostraría el texto
undefined. Cada elemento de .map es un Route de @angular/router, donde
path y title son opcionales; por eso la plantilla de texto
`${item.title}` compila aunque falte el título.
La plantilla side-menu.component.html marca la página activa:
<div class="list-group">
@for (item of reactiveMenu; track item.title) {
<a
class="list-group-item list-group-item-action"
[routerLink]="item.route"
routerLinkActive="active"
>{{ item.title }}</a
>
}
</div>
routerLinkActive="active" agrega la clase active al elemento cuando su
ruta coincide con la URL actual. La clase se agrega siempre; que se vea
pintada depende del CSS. En Bootstrap, .active solo tiene estilo sobre
.list-group-item. Antes el enlace era un <a> suelto dentro de un
<li class="list-group-item">: Angular ponía active en el <a>, y esa clase
no hacía nada visible. Poniendo list-group-item en el propio <a>, el
enlace se pinta de azul cuando está activo.
Errores comunes¶
- Escribir mal
container(containter): la fila se desborda y aparece scroll horizontal. - Poner
routerLinkActiveen un elemento sin estilo para.active: la clase se agrega pero no se ve nada. - Mezclar Bootstrap con Tailwind o daisyUI en el mismo
styles: ambos definen clases como.btn,.cardy.container, y chocan. En este proyectoangular.jsonsolo cargastyles.scss.
Preguntas de entrevista¶
1. ¿Para qué sirve routerLinkActive y por qué a veces «no funciona»?¶
Respuesta:
Agrega una clase CSS al elemento cuando su routerLink coincide con la URL
actual. Casi siempre «no funciona» porque la clase sí se agrega, pero el CSS no
tiene ninguna regla para ella en ese elemento. Se comprueba en las
herramientas del navegador: si aparece active en el <a>, el problema es de
estilos, no de Angular.
2. ¿Qué diferencia hay entre @use 'bootstrap/scss/bootstrap' y @use 'bootstrap/scss/bootstrap-grid'?¶
Respuesta:
La primera carga todo Bootstrap: reboot, componentes y utilidades. La segunda
solo la cuadrícula (container, row, col-*) y unas pocas utilidades de
espacio y flex. Si solo necesitas ordenar la página en columnas, la segunda
pesa mucho menos.
3. ¿Por qué .row necesita estar dentro de un .container?¶
Respuesta:
.row usa márgenes negativos para compensar el relleno de sus columnas.
.container aporta el relleno que los absorbe. Sin él, la fila se extiende
más allá del ancho de la pantalla y aparece scroll horizontal.
4. ¿Por qué se filtra la ruta '**' al armar el menú?¶
Respuesta:
Porque esa ruta solo redirige (redirectTo: 'basic') y no tiene title. Si
entrara al menú, mostraría un enlace con el texto undefined, porque
item.title es opcional en el tipo Route.
Módulo RF2: FormGroup y FormControl¶
El problema que resuelve¶
Tienes un formulario con tres campos: producto, precio y existencias. Quieres
saber en cualquier momento qué escribió el usuario, si los datos son válidos y
si ya tocó un campo, sin ir leyendo cada <input> del DOM. En los
formularios reactivos, el componente guarda un objeto por campo y la plantilla
solo lo enlaza.
Las dos piezas¶
FormControles un campo: guarda su valor y su estado.FormGroupes un grupo de campos: reúne varios controles y calcula el estado del conjunto (por ejemplo, «el formulario es válido solo si todos los campos lo son»).
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'
myForm = new FormGroup({
name: new FormControl(''),
price: new FormControl(0),
inStorage: new FormControl(0),
})
En el repositorio esta forma está comentada, porque el proyecto pasó a
FormBuilder (Módulo RF3). Se deja
como referencia de «la forma tradicional».
Los parámetros de FormGroup¶
new FormGroup(controls, validatorOrOpts, asyncValidator):
controls: un objeto cuyas claves son los nombres de los campos y cuyos valores son losFormControl. Es el único obligatorio.validatorOrOpts: validadores síncronos para todo el grupo (por ejemplo, «la contraseña y su repetición deben coincidir»), o un objeto de opciones convalidators,asyncValidatorsyupdateOn.asyncValidator: validadores asíncronos del grupo, como una consulta al servidor.
La mayoría de las veces solo se usa el primero; los validadores van en cada campo.
El valor inicial y el tipo¶
new FormControl('') da el campo un punto de partida: el texto vacío. Con un
valor inicial, TypeScript deduce el tipo solo: new FormControl('') es
FormControl<string | null>. El null está ahí porque reset() puede dejar
el campo en null. Sin valor inicial, new FormControl() es
FormControl<any> y se pierde la ayuda del compilador.
Conectarlo a la plantilla¶
imports: [JsonPipe, ReactiveFormsModule],
ReactiveFormsModule trae las directivas que hacen falta. Sin importarlo,
[formGroup] da error de «propiedad desconocida». En la plantilla:
<form autocomplete="off" [formGroup]="myForm">
<input
type="text"
class="form-control"
placeholder="Product name"
formControlName="name"
/>
[formGroup]="myForm"le dice al<form>qué grupo manda.formControlName="name"enlaza ese<input>con el control que tenga la clavenamedentro de ese grupo (aquí,myForm.controls['name']). Angular mantiene sincronizados el texto del input y el valor del control, en los dos sentidos.
formControlName recibe un texto, no una referencia. Si escribes
formControlName="nme", TypeScript no avisa: falla al ejecutar, porque no
existe ese control.
Errores comunes¶
- No importar
ReactiveFormsModule:[formGroup]yformControlNameno se reconocen. - Escribir mal el nombre en
formControlName: error solo al ejecutar. - Esperar que el
<input>tenga el valor inicial por sí mismo: el valor inicial va en elFormControl, no en el HTML.
Preguntas de entrevista¶
1. ¿Qué diferencia hay entre FormControl y FormGroup?¶
Respuesta:
FormControl representa un solo campo (valor, estado y validadores).
FormGroup reúne varios controles y calcula el estado del conjunto: es válido
solo si todos sus controles lo son, y su value es un objeto con el valor de
cada uno.
2. ¿Qué hace formControlName y qué necesita para funcionar?¶
Respuesta:
Enlaza un <input> con el control que tiene esa clave dentro del [formGroup]
más cercano. Necesita que el componente importe ReactiveFormsModule y que el
<input> esté dentro de un elemento con [formGroup].
3. ¿Por qué new FormControl('') es de tipo string | null y no solo string?¶
Respuesta:
Porque reset() sin argumentos deja el valor en null. Angular marca el tipo
como nullable para no prometer algo falso. Si no quieres el null, se usa
new FormControl('', { nonNullable: true }).
4. ¿Cuándo usarías los parámetros 2 y 3 de FormGroup?¶
Respuesta:
Cuando la regla depende de varios campos a la vez, como comprobar que dos
contraseñas coinciden. Una regla de un solo campo va en el FormControl.
El tercer parámetro se usa para reglas asíncronas del grupo, como validar una
combinación de campos contra el servidor.
Módulo RF3: FormBuilder y validadores¶
El problema que resuelve¶
Con new FormGroup({ name: new FormControl('') }) repites new FormControl
en cada campo y, al agregar validadores, la línea se vuelve larga. FormBuilder
es un servicio que crea los mismos objetos con menos texto.
El código¶
fb = inject(FormBuilder)
myForm: FormGroup = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
price: [0, [Validators.required, Validators.min(10)]],
inStorage: [0, [Validators.required, Validators.min(0)]],
})
inject(FormBuilder)pide el servicio, igual que en los módulos anteriores.fb.group({...})recibe un objeto: la clave es el nombre del campo y el valor es un array con la forma[valor inicial, validadores síncronos, validadores asíncronos]. El segundo y el tercer elemento son opcionales, y cada uno puede ser un validador o un array de validadores.- Sigue haciendo falta
ReactiveFormsModuleen el componente: elFormBuildercrea los objetos, pero las directivas[formGroup]yformControlNamevienen de ese módulo.
Los validadores usados¶
Un validador (validator) es una función que mira el valor y devuelve
null si está bien, o un objeto que describe el error.
| Validador | Falla cuando… | Objeto de error |
|---|---|---|
Validators.required |
el valor es null, '' o undefined |
{ required: true } |
Validators.minLength(3) |
el texto tiene menos de 3 letras | { requiredLength: 3, actualLength } |
Validators.min(10) |
el número es menor que 10 | { min: 10, actual } |
Dos detalles que sorprenden:
requiredno falla con el número0: para Angular,0es un valor. Por esoinStorageempieza en0y es válido, mientras quepriceempieza en0y falla pormin(10).minLengthno se queja de un campo vacío: eso le toca arequired. Así,nametiene un solo error a la vez:requiredsi está vacío,minlengthsi tiene 1 o 2 letras.
La anotación FormGroup y el tipado¶
myForm: FormGroup ensancha el tipo. Sin la anotación, fb.group deduce un
formulario tipado: myForm.controls.name existe y
myForm.controls['nme'] no compila. Con la anotación, el tipo pasa a
FormGroup<any>: se puede leer un control con un texto cualquiera
(controls[fieldName], que usan los métodos del
Módulo RF5),
pero TypeScript ya no revisa que el nombre exista. Es un intercambio:
flexibilidad a cambio de seguridad.
Errores comunes¶
- Poner un validador asíncrono en la segunda posición: el segundo elemento
es solo para los síncronos (
Validators.required,minLength...). Los asíncronos van en el tercero. - Esperar que
requiredrechace el0: no lo hace. - Olvidar
ReactiveFormsModuleporque ya se usaFormBuilder.
Preguntas de entrevista¶
1. ¿Qué ventaja tiene FormBuilder sobre new FormGroup?¶
Respuesta:
Escribes menos: cada campo es un array [valor, validadores] en lugar de
new FormControl(valor, validadores). El resultado es el mismo
(FormGroup y FormControl por dentro). No cambia lo que hace el formulario,
solo cómo se escribe.
2. ¿Qué significa cada posición del array de un campo?¶
Respuesta:
[valor inicial, validadores síncronos, validadores asíncronos]. Los dos
últimos son opcionales y cada uno acepta un validador o un array de
validadores.
3. ¿Qué pasa si anotas myForm: FormGroup en vez de dejar que TypeScript deduzca el tipo?¶
Respuesta:
Se pierde el tipado de los campos. Con la anotación, controls['cualquierCosa']
compila aunque no exista, y falla al ejecutar. Sin ella, el compilador conoce
los nombres (name, price, inStorage) y avisa de los errores de escritura.
4. ¿Validators.required rechaza el número 0?¶
Respuesta:
No. Solo considera vacíos a null, undefined, el texto '' y un array sin
elementos. Si 0 no debe ser válido, hay que sumar Validators.min(1).
Módulo RF4: El estado del formulario¶
El problema que resuelve¶
Mientras desarrollas quieres ver qué piensa Angular de tu formulario: si es
válido, si el usuario ya lo tocó, qué valor tiene. En la parte de abajo de
basic-page.component.html hay un panel de depuración que lo muestra con el
pipe json (Módulo PP6).
El código¶
<span>Valid</span>
<pre>{{ myForm.valid | json }}</pre>
<span>Pristine</span>
<pre>{{ myForm.pristine | json }}</pre>
<span>Touched</span>
<pre>{{ myForm.touched | json }}</pre>
<span>Value</span>
<pre>{{ myForm.value | json }}</pre>
| Propiedad | Pregunta que responde |
|---|---|
valid |
¿Todos los validadores pasan? |
pristine |
¿El usuario no ha cambiado ningún valor? (lo contrario es dirty) |
touched |
¿Algún campo perdió el foco alguna vez? (lo contrario es untouched) |
value |
Un objeto con el valor de cada campo |
errors |
Los errores del control, o null si no hay |
pristine y touched no son lo mismo: cambiar el texto de un campo lo hace
dirty, pero solo pasa a touched cuando sales de él.
Los errores de cada campo¶
<!-- errors: null when the field is valid, or an object keyed by the validator that failed (required, minlength, min). The group itself has no validators, so myForm.errors is always null; each field reports its own errors. -->
<span>Name Field Errors</span>
<pre>{{ myForm.controls['name'].errors | json }}</pre>
myForm.errors es null porque el grupo no tiene validadores propios. Los
errores se leen en el control de cada campo. Con el nombre 'ab', por ejemplo,
se ve { "minlength": { "requiredLength": 3, "actualLength": 2 } }.
El error del JSON circular¶
La versión de aprendizaje intentó mostrar un control entero:
<pre>{{ myForm.controls['price'] | json }}</pre>
Y el navegador respondió: Converting circular structure to JSON. El pipe
json usa JSON.stringify, que no sabe convertir un objeto que se contiene a
sí mismo. Un FormControl guarda una referencia a su padre (parent, el
FormGroup), y el FormGroup guarda a sus controles: control → padre →
control. Es un círculo.
myForm.value no falla porque es un objeto plano con solo los valores, sin
referencias al padre. La solución es pedir una propiedad concreta del control:
<pre>{{ myForm.controls['price'].value | json }}</pre>
Las mismas propiedades sirven para cualquier control: .value, .valid,
.errors, .touched.
Errores comunes¶
- Pasar un
FormControlo unFormGroupcompleto ajson: referencia circular. Usa una propiedad. - Confundir
pristineconuntouched: uno mira si cambió el valor; el otro, si el campo perdió el foco. - Esperar que
myForm.errorstraiga los errores de los campos: solo trae los del propio grupo.
Preguntas de entrevista¶
1. ¿Por qué {{ myForm.value | json }} funciona y {{ myForm.controls['price'] | json }} no?¶
Respuesta:
value es un objeto plano con los valores. Un FormControl completo tiene una
referencia a su parent (el grupo), y el grupo tiene sus controles: es una
estructura circular, y JSON.stringify no puede convertirla.
2. ¿Qué diferencia hay entre touched y dirty?¶
Respuesta:
dirty significa que el usuario cambió el valor del campo. touched
significa que el campo perdió el foco al menos una vez. Se puede estar
touched sin dirty (entras y sales sin escribir) y dirty sin touched
(escribes y todavía no sales).
3. ¿Por qué myForm.errors es null aunque un campo sea inválido?¶
Respuesta:
Porque errors de un grupo solo contiene los errores de los validadores del
propio grupo. Los errores de cada campo viven en su control:
myForm.controls['name'].errors. Lo que sí propaga el grupo es el estado:
myForm.valid es false si algún control falla.
Módulo RF5: Mensajes de error con FormUtils¶
El problema que resuelve¶
Quieres mostrar «Este campo es obligatorio» solo cuando hay un error y el usuario ya tocó el campo (si no, el formulario nace lleno de mensajes rojos). Si pones esa condición directo en la plantilla, se repite en cada campo.
La primera versión (queda comentada)¶
@if (myForm.controls?.['name'].errors?.['required'] && myForm.controls['name'].touched) {
<span class="form-text text-danger"> The name is required </span>
}
@if (myForm.controls?.['name'].errors?.['minlength'] && myForm.controls['name'].touched) {
<span class="form-text text-danger">
Must be {{ myForm.controls?.['name'].errors?.['minlength']?.requiredLength }} letters
long
</span>
}
Funciona, pero no es buena práctica: la condición se repite por cada error y por cada campo, y la plantilla se llena de lógica. Con tres campos y dos validadores cada uno serían unos seis bloques como estos.
La versión actual: una clase compartida¶
La lógica salió del componente a src/app/shared/utils/form-utils.ts, una
clase FormUtils con métodos estáticos (no guarda estado, así que no hace falta
crear instancias). Cada página la guarda en una propiedad
(formUtils = FormUtils) para poder llamarla desde la plantilla:
export class FormUtils {
static hasControlError(control: AbstractControl | null | undefined): boolean {
return !!control?.errors && control.touched
}
static hasFieldError(form: FormGroup, field: string): boolean {
return FormUtils.hasControlError(form.controls[field])
}
static hasFieldErrorInArray(formArray: FormArray, index: number): boolean {
return FormUtils.hasControlError(formArray.controls[index])
}
static getFieldError(form: FormGroup, fieldName: string): string | null {
return FormUtils.getControlError(form.controls[fieldName])
}
static getFieldErrorInArray(formArray: FormArray, index: number): string | null {
return FormUtils.getControlError(formArray.controls[index])
}
static getControlError(control: AbstractControl | null | undefined): string | null {
if (!control?.errors) return null
const errors = control.errors
// Return the message of the first known error. Unknown keys are skipped.
for (const key of Object.keys(errors)) {
switch (key as ControlValidationError) {
case 'required':
return FORM_REQUIRED_ERROR
case 'minlength':
return FORM_MIN_LENGTH_ERROR(errors['minlength'].requiredLength)
case 'min':
return FORM_MIN_VALUE_ERROR(errors['min'].min)
}
}
return null
}
}
Y la plantilla queda igual para los tres campos:
@if (formUtils.hasFieldError(myForm, 'name')) {
<span class="form-text text-danger">{{ formUtils.getFieldError(myForm, 'name') }}</span>
}
hasFieldErrorresponde «¿hay que mostrar un error?»: hay errores y el campo fue tocado. El!!convierte el objeto de errores (onull) entrueofalse.getFieldErrortraduce la clave del error a un texto. Recorre las claves deerrors, devuelve el mensaje de la primera que conoce y, si ninguna coincide,null. Los datos del mensaje (requiredLength,min) salen del propio objeto de error.- Los textos viven aparte, en
shared/constants/form.constants.ts(FORM_REQUIRED_ERROR,FORM_MIN_LENGTH_ERROR,FORM_MIN_VALUE_ERROR), y los nombres de los errores que se conocen están en el tipoControlValidationError.
Los métodos reciben el nombre del campo como string (no como 'name' |
'price' | …) porque el parámetro es un FormGroup sin tipos de campos
(Módulo RF3). Un nombre mal escrito
no lo detecta TypeScript: el método solo responde false.
Esta es la versión de esta etapa: conoce tres errores. Más adelante la clase
aprende email, pattern y un error propio, y getFieldError recibe un
tercer parámetro opcional
(Módulo RF9).
Un campo suelto y un elemento de un array son lo mismo¶
Un campo del formulario y un elemento de un FormArray (por ejemplo, la lista
de juegos favoritos de la página dynamic) son los dos un AbstractControl:
tienen errors y touched. La regla «hay errores y fue tocado» es
idéntica, así que vive en un solo sitio, hasControlError, y el mensaje en
getControlError. Los otros métodos solo buscan el control (por nombre o
por posición) y se lo pasan:
@for (game of favoriteGames.controls; track game; let i = $index) {
<input class="form-control" [formControlName]="i" />
@if (formUtils.hasFieldErrorInArray(favoriteGames, i)) {
<span class="form-text text-danger">
{{ formUtils.getFieldErrorInArray(favoriteGames, i) }}
</span>
}
}
Si el control no existe (un nombre mal escrito, o una posición que
desapareció al borrar un elemento), hasControlError devuelve false y
getControlError devuelve null en vez de romper la página.
El nombre cuenta¶
Esos métodos se llamaron antes isValidField e isValidFieldinArray, pero
devolvían true justo cuando el campo era inválido: @if
(isValidField('name')) se leía al revés. Con hasFieldError el @if dice lo
que pasa: «si el campo tiene un error, muéstralo».
Por qué no un mensaje fijo¶
Una versión aún más simple sería errors && touched con un texto fijo como
«Must be 3 letters long». Falla en un caso: si el campo está vacío, el error
real es required, pero el mensaje diría «3 letras». Por eso hay que mirar
qué validador falló, no solo que falló.
Cómo se llegó aquí¶
Las dos funciones nacieron dentro del componente basic. Al llegar la página
dynamic, que necesitaba lo mismo, copiarlas habría duplicado la lógica, así
que pasaron a shared/utils/. Después se vio que la versión para un campo y la
de un elemento de array hacían lo mismo sobre un control, y se quedó un solo
par de métodos (hasControlError y getControlError) con envoltorios por
nombre.
Errores comunes¶
- Mostrar el error sin mirar
touched: el formulario nace con mensajes rojos. - Un mensaje fijo para un validador que no es el que falló.
- Llamar a un método con lógica pesada desde la plantilla: se ejecuta en cada comprobación de cambios. Aquí es barato (lee unas propiedades), pero conviene tenerlo presente.
Preguntas de entrevista¶
1. ¿Por qué se comprueba touched además de errors?¶
Respuesta:
Porque el formulario empieza vacío e inválido. Sin touched, el usuario vería
todos los errores antes de escribir nada. Con touched, el error aparece
cuando el usuario sale del campo.
2. ¿Por qué los mensajes viven en el componente y no en la plantilla?¶
Respuesta:
La plantilla debería describir qué se muestra, no calcular cuándo y con qué texto. Mover la lógica a métodos evita repetirla en cada campo, permite cambiar un mensaje en un solo sitio y deja la plantilla casi igual para todos los campos.
3. ¿Por qué hay un método para un campo y otro para un elemento de array, si hacen lo mismo?¶
Respuesta:
Los dos trabajan sobre un AbstractControl, así que la regla vive en un único
método que recibe el control (hasControlError). Los otros solo encuentran el
control, por nombre en un FormGroup o por posición en un FormArray, y se
lo pasan. Si la regla cambia, se cambia en un sitio, y la plantilla sigue
llamando por nombre sin saber cómo se resuelve.
4. ¿Qué hace getFieldError si un campo tiene dos errores a la vez?¶
Respuesta:
Devuelve solo el mensaje del primero que reconoce, según el orden de las
claves de errors. Si necesitas mostrar todos, habría que acumularlos en un
array en vez de hacer return dentro del bucle.
5. ¿Cómo se construye el texto «Minimum length is 3»?¶
Respuesta:
El validador minLength(3) devuelve { minlength: { requiredLength: 3,
actualLength: n } }. El método lee errors['minlength'].requiredLength y lo
inserta en el mensaje, así el número no está repetido a mano.
Módulo RF6: Cuando el formulario crece¶
El problema que resuelve¶
El formulario de este proyecto tiene tres campos y ya ocupa bastante código. Este módulo no agrega nada nuevo al proyecto: explica hasta dónde llega este enfoque y qué se hace cuando se queda corto. Sirve para responder bien la pregunta de entrevista «¿qué problemas tienen los formularios reactivos?».
Dónde se complica¶
- Se lee mal. Cada campo es un array con valor y validadores. Con grupos
dentro de grupos (por ejemplo, una dirección con calle, ciudad y código
postal dentro de un
FormGrouppropio) la indentación crece y el formulario se pierde entre llaves y corchetes. - La plantilla y la clase deben coincidir a mano. Cada
formControlName="price"es un texto. Si lo escribes mal o renombras el campo en la clase, TypeScript no avisa: falla al ejecutar. En formularios anidados el acceso se alarga:myForm.controls.address.controls.street.value. - Los niveles multiplican el trabajo. Cada grupo anidado necesita su
formGroupNameen la plantilla, su acceso a los errores y sus validadores. UnFormArraydentro de unFormGroupdentro de otro es difícil de seguir. - La clase se llena de mezcla. Estructura, valores iniciales, validadores y mensajes terminan en el mismo bloque, y cuesta ver qué valida cada campo.
- Se repite mucho. Los mismos validadores y mensajes aparecen en varios
campos y varias páginas. Por eso
hasFieldErrorygetFieldErrorya salieron del componente a la clase compartidaFormUtils(Módulo RF5).
Lo que lo alivia¶
FormBuilder(fb.group({...})), que ya se usa, para escribir menos.- Partir el formulario en subcomponentes, uno por grupo anidado, para que cada archivo sea corto.
- Sacar validadores y mensajes de error a funciones compartidas, en vez de repetirlos en cada componente.
- Dejar el tipado activo. Quitar la anotación
FormGroupdemyForm(Módulo RF3) hace que el compilador detecte nombres mal escritos en el código.
Lo que todavía falta¶
El botón Guardar de basic llama a formUtils.onSave(myForm) con
(ngSubmit): si el formulario es inválido marca todos los campos como tocados
(para que salgan los errores) y, si es válido, lo vacía con reset(). Los
datos todavía no se envían a ningún sitio. Ese paso llega más adelante. Las
páginas dynamic, switches y el registro tampoco envían nada todavía: solo
muestran lo que está mal.
Errores comunes¶
- Meter todo el formulario en un solo componente gigante. Parte por grupos.
- Copiar y pegar los mensajes de error en cada página.
- Anidar grupos sin necesidad: un grupo anidado es útil cuando representa algo real (una dirección), no para «ordenar» tres campos.
Preguntas de entrevista¶
1. ¿Qué problemas tiene un formulario reactivo grande?¶
Respuesta:
Que el código crece en vertical: estructura, validadores, mensajes y acceso a
los controles viven juntos. Los nombres de los campos son textos que el
compilador no comprueba (formControlName), los grupos anidados exigen
formGroupName en la plantilla y acceso encadenado en el código, y la lógica
de errores suele repetirse en cada página.
2. ¿Cómo lo mantendrías legible?¶
Respuesta:
Con FormBuilder para escribir menos, un subcomponente por grupo anidado,
validadores y mensajes en funciones compartidas, y el tipado del formulario
activo para que el compilador revise los nombres de los campos.
3. ¿Cuándo tiene sentido un FormGroup anidado?¶
Respuesta:
Cuando los campos forman una unidad con significado propio y se validan o se envían juntos, como una dirección. Si el grupo solo existe para ordenar la vista, añade niveles de acceso y de plantilla sin dar nada a cambio.
Módulo RF7: Listas dinámicas con FormArray¶
El problema que resuelve¶
En la página basic los campos son fijos: producto, precio y existencias. En
la página dynamic hay una lista de juegos favoritos y el usuario decide
cuántos hay: agrega uno, borra otro. Un FormGroup no sirve para eso, porque
sus claves se escriben una vez en el código. Para una lista que crece y se
encoge existe FormArray.
Las tres piezas, ahora con una lista¶
myForm (FormGroup)
├── name -> FormControl
└── favoriteGames -> FormArray
├── [0] 'Metal Gear' -> FormControl
└── [1] 'Final Fantasy' -> FormControl
FormControl: un campo.FormGroup: campos con nombre fijo (name,price).FormArray: campos con posición (0, 1, 2…) y cantidad variable.
Crear el array¶
myForm: FormGroup = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
favoriteGames: this.fb.array(
[
['Metal Gear', Validators.required],
['Final Fantasy', Validators.required],
],
[Validators.minLength(3)],
),
})
fb.array(controles, validadores) recibe dos cosas:
- Los elementos, con la misma forma abreviada de
fb.group:[valor inicial, validadores]. Cada juego empieza con su valor y conrequired(un juego vacío es un error). - Los validadores del array completo.
Validators.minLength(3)aquí significa «la lista debe tener al menos 3 juegos», no «cada juego debe tener 3 letras». Como arranca con 2, la lista es inválida hasta que el usuario agrega un tercero.
Un getter para llegar al array¶
get favoriteGames() {
return this.myForm.get('favoriteGames') as FormArray
}
Un getter es una propiedad que ejecuta una función cada vez que se lee.
myForm.get(...) devuelve el tipo genérico AbstractControl; el as FormArray
le dice a TypeScript lo que ya sabemos, y así se pueden usar push,
removeAt, clear y controls. Con el getter, la plantilla escribe
favoriteGames en vez de repetir myForm.get('favoriteGames') as FormArray.
Dibujar la lista en la plantilla¶
<div class="col-sm-9" formArrayName="favoriteGames">
@for (game of favoriteGames.controls; track game; let i = $index) {
<input class="form-control" [formControlName]="i" />
<button type="button" (click)="onDeleteFavorite(i)">Delete</button>
}
</div>
formArrayName="favoriteGames"dice: dentro de estediv, los controles son los elementos de ese array.- Dentro de un
formArrayName,formControlNamerecibe la posición (un número), no un nombre. Por eso va entre corchetes:[formControlName]="i". let i = $indexda la posición de cada elemento. Se escribe con$; sin él, Angular no reconoce la variable y el componente no compila.
Por qué track game y no track $index¶
Con track $index, Angular reconoce cada <input> por su posición. Si
vacías la lista y la vuelves a llenar, las posiciones 0 y 1 siguen existiendo,
así que Angular reutiliza los mismos inputs. Pero esos inputs siguen
conectados a los controles viejos, que ya no están en el array. El
resultado: los valores nuevos existen en el modelo y el formulario los muestra
vacíos.
Con track game (game es el propio FormControl), cada input se reconoce
por su control. Un control nuevo es un input nuevo, bien conectado.
Agregar y borrar¶
newFavorite = new FormControl('', Validators.required)
onAddFavorite() {
if (this.newFavorite.invalid) return
// ...se comprueba que no esté repetido (abajo)...
this.favoriteGames.push(this.fb.control(newGame, Validators.required))
this.newFavorite.reset()
}
onDeleteFavorite(index: number) {
this.favoriteGames.removeAt(index)
}
pushagrega un control al final yremoveAt(i)quita el de la posicióni. La plantilla se redibuja sola.newFavoritees unFormControlsuelto, fuera demyForm: es solo el cuadro donde se escribe el juego antes de pulsar «Add favorite». Si estuviera dentro demyForm, surequireddejaría inválido todo el formulario cada vez que el cuadro está vacío. Por eso el<input>usa[formControl]="newFavorite"(un control, sin grupo) en vez deformControlName.- Los botones de agregar y borrar llevan
type="button". Un botón dentro de un<form>sin tipo es de envío, y enviaría el formulario.
Agregar con la tecla Enter¶
<input
class="form-control"
[formControl]="newFavorite"
(keydown.enter)="$event.preventDefault(); onAddFavorite()"
/>
Dentro de un <form>, pulsar Enter en un cuadro de texto envía el
formulario. Aquí no queremos eso: queremos agregar el juego. Por eso el
evento hace dos cosas: preventDefault() cancela el envío y onAddFavorite()
agrega el juego.
Hay que escucharlo en keydown y no en keyup. Al pulsar Enter pasa esto, en
este orden:
keydown: el navegador decide enviar el formulario. Aquí todavía se puede cancelar.submit: el formulario ya se envió y(ngSubmit)ejecutóonSubmit().keyup: se suelta la tecla. Ya no hay nada que cancelar.
preventDefault() solo cancela la acción por defecto del evento en el que se
llama, y solo si llega antes de que ocurra. En keyup llega tarde: el
formulario ya se envió y onSubmit() ya marcó todos los campos con error.
[formControl] o formControlName¶
Los dos conectan un <input> con un control: lo que se escribe actualiza
control.value, y el estado (valid, touched, dirty) se mantiene al día.
Lo único que cambia es cómo encuentra Angular el control.
[formControl]="newFavorite" |
formControlName="name" |
|
|---|---|---|
| Qué le pasas | El objeto FormControl |
El nombre del control (o su posición, dentro de un formArrayName) |
| Necesita un grupo encima | No | Sí: un [formGroup] o un formArrayName |
| Quién encuentra el control | Tú, desde la clase | Angular, buscándolo en el grupo |
| Cuándo usarlo | Un control suelto, fuera de un formulario | Un campo que forma parte del FormGroup |
formControlName="name" equivale a myForm.controls['name']: Angular mira el
[formGroup] más cercano y busca ahí. Por eso, si el <input> no está dentro
de un [formGroup], falla: no sabe en qué grupo buscar.
En esta pantalla están las dos formas:
formControlName="name": el campo vive dentro demyForm.[formControlName]="i": los juegos de la lista, donde el «nombre» es la posición.[formControl]="newFavorite": el cuadro de «agregar», que está fuera demyForma propósito.
Regla corta: si el control está dentro de un grupo o array, formControlName;
si está suelto, [formControl].
Evitar duplicados al agregar¶
const newGame = (this.newFavorite.value ?? '').trim()
if (!newGame) return
const exists = this.favoriteGames.controls.some(
(control) => (control.value ?? '').trim().toLowerCase() === newGame.toLowerCase(),
)
if (exists) {
this.addFavoriteError.set(FORM_DUPLICATED_ERROR)
return
}
Se compara sin espacios y sin distinguir mayúsculas, para que «zelda» y
«Zelda » cuenten como el mismo juego. El mensaje sale de una signal del
componente (addFavoriteError), no de un validador: el duplicado nunca entra
al array, así que Angular no tiene ningún error que informar. Decidimos y
mostramos el mensaje nosotros.
Los errores: del elemento y del array¶
Hay dos niveles de error, y se preguntan distinto (Módulo RF5):
<!-- Error de UN elemento (por ejemplo, está vacío): se pregunta por posición -->
@if (formUtils.hasFieldErrorInArray(favoriteGames, i)) {
<span class="form-text text-danger">{{ formUtils.getFieldErrorInArray(favoriteGames, i) }}</span>
}
<!-- Error de la LISTA entera (mínimo de juegos): el array es un campo de myForm -->
@if (formUtils.hasFieldError(myForm, 'favoriteGames')) {
<span class="form-text text-danger">{{ formUtils.getFieldError(myForm, 'favoriteGames') }}</span>
}
Los errores del array se pueden ver en vivo con
{{ favoriteGames.errors | json }}: null si cumple el mínimo, o
{ "minlength": { "requiredLength": 3, "actualLength": 2 } } si no.
Guardar y reiniciar¶
onSubmit() {
this.myForm.markAllAsTouched()
}
reset() {
this.myForm.reset({ name: '' })
this.favoriteGames.clear()
for (const game of ['Metal Gear', 'Final Fantasy']) {
this.favoriteGames.push(this.fb.control(game, Validators.required))
}
this.newFavorite.reset()
this.addFavoriteError.set(null)
}
onSubmitsolo muestra lo que está mal (markAllAsTouched). No usaFormUtils.onSaveporque suform.reset()dejaría la lista vacía. Enviar los datos a algún sitio llega más adelante.FormArray.reset(valores)solo reinicia los elementos para los que recibe un valor. Si el usuario agregó 3 juegos y reinicias con 2 valores, el tercero sigue ahí, vacío. Por eso se vacía la lista conclear()y se vuelve a construir la inicial. Tampoco sirve pasar[['Metal Gear'], ['Final Fantasy']]: cada elemento recibiría un array como valor, no un texto.newFavoriteestá fuera demyForm, así quemyForm.reset()no lo toca: se limpia aparte.
Errores comunes¶
- Usar
track $indexen una lista de controles: trasclear()o un borrado los inputs quedan conectados a controles que ya no existen. - Escribir
let i = indexen vez delet i = $index. - Pasar la posición a
[formControl]en vez de a[formControlName]dentro de unformArrayName. - Meter el cuadro de «agregar» dentro del formulario: su
requiredbloquea el guardado. - Esperar que
FormArray.reset()quite los elementos sobrantes: no lo hace. - Dejar un botón sin
type="button"dentro del<form>: envía el formulario. - Cancelar el Enter en
keyup: el envío ocurre enkeydown, así quepreventDefault()llega tarde.
Preguntas de entrevista¶
1. ¿Cuándo usas FormArray y cuándo FormGroup?¶
Respuesta:
FormGroup cuando los campos tienen un nombre fijo y conocido al escribir el
código (name, price). FormArray cuando hay una lista de campos del mismo
tipo y la cantidad cambia mientras el usuario usa la página, como los teléfonos
o los juegos favoritos. El array identifica a sus elementos por posición.
2. ¿Qué significa Validators.minLength(3) en un FormArray?¶
Respuesta:
Que la lista debe tener al menos 3 elementos. Es un validador del array, no
de cada elemento: su error vive en favoriteGames.errors, no en el control de
un juego. Para exigir algo a cada elemento, el validador va en cada control
(Validators.required en cada juego).
3. ¿Por qué los inputs quedaban vacíos después de reiniciar con track $index?¶
Respuesta:
Porque Angular reutiliza los elementos del DOM que tienen la misma clave. Con
track $index, la clave es la posición, que no cambia al vaciar y rellenar la
lista. Los inputs se quedan conectados a los controles antiguos, que ya no
están en el array. Con track sobre el propio control, un control nuevo
produce un input nuevo.
4. ¿Qué recibe formControlName dentro de un formArrayName?¶
Respuesta:
La posición del elemento en el array, un número. En un FormGroup recibe el
nombre del campo, un texto. Por eso dentro de la lista se escribe
[formControlName]="i", con corchetes, para que Angular lo evalúe como número.
5. ¿Por qué el cuadro para escribir el nuevo favorito no está dentro del formulario?¶
Respuesta:
Porque es solo un auxiliar para escribir antes de agregar. Si formara parte de
myForm, su required haría inválido todo el formulario cada vez que esté
vacío, su valor aparecería en myForm.value y no se podría guardar. Fuera del
grupo, myForm solo contiene lo que de verdad se guarda.
Módulo RF8: Radios, switches y casillas¶
El problema que resuelve¶
Hasta ahora todos los campos eran cuadros de texto o números. Un formulario
real también tiene opciones para elegir: un género (una opción entre varias),
un interruptor de notificaciones (sí o no) y una casilla de «acepto los
términos» (que debe estar marcada para poder guardar). La pantalla switches
junta los tres.
Lo bueno: se conectan igual que un cuadro de texto, con formControlName. Lo
que cambia es el tipo de valor que guarda cada control.
El código del repositorio¶
myForm = this.fb.group({
gender: [this.formGender, Validators.required],
wantNotifications: [true],
termsAndConditions: [false, Validators.requiredTrue],
})
| Campo | Tipo de input | Valor que guarda | Validador |
|---|---|---|---|
gender |
radio (dos opciones) | 'M' o 'F' |
required |
wantNotifications |
checkbox con estilo de switch | true o false |
ninguno |
termsAndConditions |
checkbox | true o false |
requiredTrue |
Radios: varios inputs, un solo control¶
<input type="radio" value="M" formControlName="gender" />
<input type="radio" value="F" formControlName="gender" />
Los dos <input> apuntan al mismo control (gender). Cada uno aporta su
value. Al marcar uno, el control guarda el value de ese input; al marcar el
otro, cambia. Por eso solo puede estar elegida una opción a la vez.
El valor inicial ('M') viene de formGender, una propiedad de la clase con
el tipo Gender = 'M' | 'F'. Con ese valor, el radio «Male» arranca marcado.
Switch y casilla: un booleano¶
<input class="form-check-input" type="checkbox" formControlName="wantNotifications" />
Un checkbox guarda true si está marcado y false si no. El «switch» de
Bootstrap es el mismo checkbox con otra clase (form-switch en el div que
lo envuelve): cambia el dibujo, no el funcionamiento. Se escribe [true]
como valor inicial para que arranque encendido.
required no sirve para una casilla: requiredTrue¶
Con Validators.required, una casilla desmarcada pasa la validación,
porque false es un valor (no está vacío). Para obligar a que el usuario la
marque hace falta Validators.requiredTrue: solo acepta true.
termsAndConditions: [false, Validators.requiredTrue]
Si la casilla está sin marcar, el control queda inválido y el formulario no se puede guardar.
Mostrar el error de los términos¶
@if (formUtils.hasFieldError(myForm, 'termsAndConditions')) {
<span class="form-text text-danger"> {{ texts.termsError }} </span>
}
Es la misma FormUtils.hasFieldError del
Módulo RF5: responde «¿hay error
y el usuario ya tocó el campo?». El mensaje no sale de getFieldError
sino de un texto propio de esta pantalla («You must accept the terms of use»),
porque el mensaje genérico («This field is required») no explica bien qué hay
que hacer con una casilla.
Los textos en un solo objeto¶
La pantalla tiene cinco textos (los dos géneros, la etiqueta de notificaciones,
la de términos y el error). En vez de cinco propiedades sueltas en el
componente, viven en un objeto en form.constants.ts:
export const SWITCHES_PAGE_TEXTS = {
termsError: 'You must accept the terms of use',
notifications: 'I want to receive notifications',
terms: 'Terms and conditions of use',
genderMale: 'Male',
genderFemale: 'Female',
} as const
El componente solo expone un campo, texts = SWITCHES_PAGE_TEXTS, y la
plantilla escribe {{ texts.terms }}. Con as const TypeScript trata cada
texto como un valor fijo y avisa si escribes mal una clave.
Guardar¶
onSubmit() {
if (this.myForm.invalid) {
this.myForm.markAllAsTouched()
return
}
}
Si el formulario es inválido, markAllAsTouched() hace visibles todos los
errores (aquí, el de los términos). Si es válido, por ahora no hace nada: enviar
los datos llega más adelante.
Errores comunes¶
- Usar
Validators.requireden una casilla de términos: una casilla desmarcada valefalse, yfalsecuenta como valor, así que pasa. UsarequiredTrue. - Poner
formControlNamedistinto en cada radio: entonces son controles separados y se pueden marcar los dos. Un grupo de radios comparte el mismoformControlName. - Olvidar el
valuede los radios: sin él, el control no sabe qué guardar al marcar uno. - Esperar texto en un checkbox: su valor es
trueofalse, no el texto de la etiqueta.
Preguntas de entrevista¶
1. ¿Cómo se conecta un grupo de radios a un formulario reactivo?¶
Respuesta:
Todos los <input type="radio"> del grupo llevan el mismo
formControlName, y cada uno su propio value. El control guarda el value
del radio que está marcado. Como comparten control, solo uno puede estar
elegido a la vez.
2. ¿Qué diferencia hay entre Validators.required y Validators.requiredTrue?¶
Respuesta:
required falla cuando el valor está vacío (null, '', un array vacío).
false no está vacío, así que pasa. requiredTrue falla salvo que el valor
sea exactamente true. Por eso requiredTrue es el validador de las casillas
que el usuario está obligado a marcar, como «acepto los términos».
3. ¿Qué valor guarda un checkbox en un formulario reactivo?¶
Respuesta:
Un booleano: true si está marcado y false si no. Un switch de Bootstrap es
el mismo checkbox con otro aspecto, así que guarda lo mismo.
4. ¿Por qué agrupar los textos de una pantalla en un objeto?¶
Respuesta:
Porque el componente se queda con un solo campo en vez de uno por texto, la
plantilla usa un nombre corto (texts.terms) y los textos quedan juntos en un
archivo, lo que facilita cambiarlos o traducirlos sin tocar la lógica. Con
as const, TypeScript conoce las claves exactas y detecta un nombre mal
escrito.
Módulo RF9: Validación entre campos y patrones¶
El problema que resuelve¶
La pantalla de registro (/auth/sign-up) pide nombre, email, usuario,
contraseña y confirmación de contraseña. Hay dos tipos de regla:
- Reglas de un campo: el email tiene que parecer un email, el nombre tiene que ser nombre y apellido, la contraseña tiene que tener al menos 6 caracteres.
- Una regla de dos campos: la contraseña y su confirmación tienen que ser iguales.
Los validadores de un campo solo ven el valor de ese campo, así que no sirven para la segunda regla. Hace falta un validador que viva más arriba, donde se vean los dos.
El código del repositorio¶
myForm = this.fb.group(
{
name: ['', [Validators.required, Validators.pattern(FormUtils.namePattern)]],
email: [
'',
[Validators.required, Validators.pattern(FormUtils.emailPattern)],
[FormUtils.checkingServerResponse],
],
username: [
'',
[
Validators.required,
Validators.pattern(FormUtils.alphanumericPattern),
FormUtils.checkForTakenValue,
],
],
password: ['', [Validators.required, Validators.minLength(6)]],
confirmPassword: ['', [Validators.required, Validators.minLength(6)]],
},
{
validators: [FormUtils.areFieldsEqual('password', 'confirmPassword')],
},
)
fb.group recibe dos argumentos: los campos y, opcionalmente, un objeto de
opciones. En validators van los validadores del grupo entero.
Dos cosas más que se ven en el código:
- El tercer elemento del campo
emailes un validador asíncrono, que se explica en el Módulo RF10. - El campo
usernamelleva, junto arequiredypattern, un validador propio de un solo campo:FormUtils.checkForTakenValue. Se explica más abajo.
Patrones: Validators.pattern¶
Un patrón (pattern) es una expresión regular: una descripción de cómo tiene
que verse un texto. Los del proyecto viven juntos en form.constants.ts:
export const PATTERNS = {
name: '^([a-zA-Z]+) ([a-zA-Z]+)$',
email: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
alphanumeric: '^[a-zA-Z0-9]+$',
} as const
^es «el texto empieza aquí» y$es «el texto termina aquí». Sin ellos, bastaría con que un pedazo del texto coincida.nameexige dos palabras con letras.alphanumeric(el usuario) solo acepta letras y números, sin espacios.- Las letras con tilde o la «ñ» no se aceptan, a propósito: así los patrones se mantienen simples. Un nombre como «José Núñez» se rechaza.
Validators.patterntambién acepta el texto directamente, como en el código de arriba.
Cuando un patrón falla, el error trae el texto de la expresión que falló:
{ "pattern": { "requiredPattern": "^([a-zA-Z]+) ([a-zA-Z]+)$", "actualValue": "Ana" } }
Ese requiredPattern es lo que permite saber cuál patrón falló:
static patternTypeError(pattern: string): string {
switch (pattern) {
case PATTERNS.email:
return FORM_EMAIL_ERROR
case PATTERNS.name:
return FORM_FULLNAME_NAME_ERROR
default:
return FORM_PATTERN_ERROR
}
}
Compara el texto del patrón que falló con los de PATTERNS y devuelve el
mensaje que corresponde. Si es otro, devuelve uno genérico («Invalid format»).
Un validador propio que compara dos campos¶
Un validador es una función que recibe un control y devuelve null (todo bien)
o un objeto que describe el error. El nombre de la clave es lo que
identifica el error después.
static areFieldsEqual(field1: string, field2: string): ValidatorFn {
const controlError: ValidationErrors = { passwordMismatch: true }
return (formGroup: AbstractControl) => {
const control1 = formGroup.get(field1)?.value
const control2 = formGroup.get(field2)?.value
if (control1 === undefined || control2 === undefined) return null
return control1 === control2 ? null : controlError
}
}
ValidationErrorses el tipo que trae Angular para el objeto de error: un objeto cuyas claves son los nombres de los errores.- Es una función que devuelve una función.
areFieldsEqual('password', 'confirmPassword')se ejecuta una vez, al construir el formulario, y entrega el validador de verdad, que ya sabe los dos nombres. Así sirve para cualquier par de campos. - Angular ejecuta esa función interna cada vez que cambia cualquier valor del grupo.
- Si un nombre no existe en el grupo, el valor es
undefined: no se puede comparar y devuelvenull. - Dos campos vacíos son iguales (
'' === ''), así que al empezar no hay desigualdad; aparece cuando el usuario escribe algo distinto.
Un validador propio para un solo campo¶
No todo validador propio compara dos campos. El nombre de usuario no puede ser uno que ya existe, y para eso basta con mirar ese campo:
static checkForTakenValue(control: AbstractControl): ValidationErrors | null {
const controlError: ValidationErrors = { usernameTaken: true }
const formValue = control.value
if (formValue === REJECTED_FIELD_VALUES.username) {
return controlError
}
return null
}
export const REJECTED_FIELD_VALUES = {
email: '...', // a fixed email that the fake server answers as taken
username: 'taken',
}
Se compara con areFieldsEqual:
areFieldsEqual |
checkForTakenValue |
|
|---|---|---|
| Dónde se pone | En el grupo (validators de fb.group) |
En el campo, junto a required y pattern |
| Qué recibe | El grupo entero | El control del campo |
| Forma | Una función que fabrica el validador (recibe los nombres) | Es el validador: no necesita argumentos |
| Cómo se pasa | areFieldsEqual('password', 'confirmPassword'), con paréntesis |
FormUtils.checkForTakenValue, sin paréntesis |
| Dónde queda el error | En myForm.errors |
En username.errors |
- Como no recibe argumentos, no hace falta que sea una función que devuelve otra: la función misma es el validador, y se pasa sin ejecutarla. Angular la llama cada vez que cambia el valor del campo.
- Como el error queda en el propio campo, el
getFieldError(myForm, 'username')de siempre lo encuentra. No hay nada especial en la plantilla. - Es síncrono: contesta al instante porque el valor «ocupado» es un dato fijo
que ya tenemos (
REJECTED_FIELD_VALUES). Si la lista de usuarios estuviera en un servidor, tendría que ser asíncrono, como el del email (Módulo RF10). - Los validadores de un campo se ejecutan todos. Si fallan dos a la vez, el
campo tiene los dos errores en
errors, ygetControlErrormuestra el primero que conoce.
Dónde queda el error: en el grupo, no en el campo¶
Este es el punto que más confunde. Como el validador está en el grupo,
Angular guarda su error en myForm.errors:
myForm.errors -> { passwordMismatch: true }
myForm.controls['confirmPassword'].errors -> null (o sus propios errores)
Por eso la plantilla no puede preguntar por confirmPassword para este error.
Pregunta por el grupo:
@if (formUtils.hasControlError(myForm)) {
<span class="form-text text-danger">{{ formUtils.getControlError(myForm) }}</span>
}
Funciona porque myForm también es un AbstractControl
(Módulo RF5): tiene errors y
touched, igual que un campo. Un grupo cuenta como tocado cuando se tocó
cualquiera de sus campos.
Angular trae myForm.hasError('passwordMismatch'), que también serviría.
Usamos hasControlError y getControlError para que el mensaje salga del mismo
sitio que los demás, con los textos en form.constants.ts.
Lo que aprendió getControlError¶
En el Módulo RF5 solo conocía
tres errores. Ahora el switch conoce más:
switch (key as ControlValidationError) {
case 'required':
return FORM_REQUIRED_ERROR
case 'minlength':
return FORM_MIN_LENGTH_ERROR(errors['minlength'].requiredLength)
case 'min':
return FORM_MIN_VALUE_ERROR(errors['min'].min)
case 'email':
return FORM_EMAIL_ERROR
case 'pattern':
return FormUtils.patternTypeError(patternRequired)
case 'passwordMismatch':
return FORM_PASSWORD_MISMATCH_ERROR
case 'usernameTaken':
return FORM_USERNAME_TAKEN_ERROR
case 'custom':
return customError ?? null
}
passwordMismatchyusernameTakenno son claves de Angular: son las que devuelven nuestrosareFieldsEqualycheckForTakenValue.customes la salida para un validador propio cuyo mensaje decide la página:getFieldError(form, campo, 'mi mensaje')lo recibe encustomError. Si no se pasa, devuelvenull.- Las claves nuevas también se agregan al tipo
ControlValidationError. Si falta ahí, TypeScript no las reconoce en elswitch. - Esta es la versión de esta etapa. Más adelante se suma
emailTaken, el error de un validador asíncrono (Módulo RF10).
El panel de depuración¶
Debajo del formulario hay un panel con el estado en vivo: valid, status,
pending, value, errors del grupo y errors de cada campo. Sirve para ver
exactamente lo anterior: al escribir contraseñas distintas, «Form Errors»
muestra { "passwordMismatch": true } y los errores de confirmPassword siguen
en null. pending es true mientras corre un validador asíncrono; el campo
email tiene uno (Módulo RF10), y
entonces pending pasa a true durante unos segundos.
Guardar¶
onSubmit() {
if (this.myForm.invalid) {
this.myForm.markAllAsTouched()
return
}
}
Igual que en las otras páginas: si el formulario es inválido se muestran todos los errores. Si es válido, todavía no se envía nada.
Errores comunes¶
- Poner la comparación de dos campos en uno de ellos: ese validador solo ve su propio valor. Va en el grupo.
- Buscar el error de contraseñas distintas en
confirmPassword: vive enmyForm.errors. Se pregunta por el grupo. - Escribir
case 'custom' || 'pattern':. El||devuelve el primer valor verdadero, que es'custom', así que elcasesolo reconoce'custom'. Para varios valores se escriben doscaseseguidos:
case 'custom':
case 'pattern':
return customError ?? null
- Un patrón sin
^y$: Angular los agrega al validar, pero entonces el texto derequiredPatternya no coincide con el dePATTERNSypatternTypeErrorcae en el mensaje genérico. - Usar
Validators.patterny norequired: un campo vacío pasa el patrón (y tambiénminLength). Por eso cada campo llevarequiredademás. - Agregar una clave nueva al
switchy olvidar el tipoControlValidationError.
Preguntas de entrevista¶
1. ¿Dónde pones un validador que compara dos campos y por qué?¶
Respuesta:
En el FormGroup que los contiene, con la opción validators de fb.group. Un
validador puesto en un campo solo recibe ese control y no tiene forma cómoda de
ver el otro; el del grupo recibe el grupo entero y lee los dos con get().
2. ¿Dónde queda el error de un validador de grupo?¶
Respuesta:
En group.errors, no en los campos. Por eso la plantilla debe preguntar por el
grupo (myForm.hasError('passwordMismatch') o, en este proyecto,
formUtils.hasControlError(myForm)). Los campos individuales siguen con sus
propios errores, que pueden ser null.
3. ¿Qué es un ValidatorFn y por qué areFieldsEqual devuelve una función?¶
Respuesta:
Un ValidatorFn es una función que recibe un control y devuelve null si es
válido o un objeto con el error. areFieldsEqual es una función que fabrica
validadores: recibe los nombres de los dos campos una vez y devuelve el
validador que los usa. Así el mismo código sirve para contraseña, email o
cualquier otro par.
4. ¿Cómo sabes qué patrón falló si Validators.pattern da siempre la clave pattern?¶
Respuesta:
El error trae requiredPattern, el texto de la expresión que falló. Se compara
con los patrones conocidos (PATTERNS) y cada uno tiene su mensaje. Si no
coincide con ninguno, se usa un mensaje genérico.
5. ¿Por qué Validators.pattern no reemplaza a Validators.required?¶
Respuesta:
Porque los validadores de patrón y de longitud mínima dan por válido un campo
vacío: no tienen nada que comprobar. Si el campo es obligatorio, hace falta
required además. Con las dos reglas, un campo vacío falla por required y
uno lleno con mal formato falla por pattern.
6. ¿Cuándo un validador propio es una función simple y cuándo una que devuelve otra función?¶
Respuesta:
Si no necesita datos de fuera, la función simple basta: es el validador y se pasa
tal cual, sin paréntesis (checkForTakenValue). Si necesita parámetros, como los
nombres de los dos campos que compara, se escribe una función que los recibe y
devuelve el validador ya configurado (areFieldsEqual('password',
'confirmPassword')). En los dos casos, lo que Angular guarda es una función que
recibe un control.
Módulo RF10: Validadores asíncronos¶
El problema que resuelve¶
Hay preguntas que el navegador no puede contestar solo. «¿Este email ya está registrado?» solo lo sabe el servidor. Un validador normal contesta al instante: recibe el valor y devuelve el resultado. Para una pregunta que tarda hace falta un validador que conteste después: un validador asíncrono (async validator).
En la pantalla de registro (/auth/sign-up), el campo email lo usa. No hay
servidor en este proyecto, así que el validador finge que lo consulta: espera
2 segundos y trata un email fijo (REJECTED_FIELD_VALUES.email) como ya ocupado.
El código del repositorio¶
function sleep() {
return new Promise((resolve) => setTimeout(resolve, 2000))
}
static async checkingServerResponse(control: AbstractControl): Promise<ValidationErrors | null> {
const controlError: ValidationErrors = { emailTaken: true }
// Simulate a server response delay
await sleep()
const formValue = control.value
// Check if the email already exists on the server
if (formValue === REJECTED_FIELD_VALUES.email) {
return controlError
}
return null
}
sleepdevuelve una promesa (Promise, «un valor que llegará más tarde») que se cumple a los 2 segundos. Representa la demora de un servidor de verdad.await sleep()detiene la función hasta que pasan los 2 segundos.- Después devuelve lo mismo que un validador normal: un objeto de error
(
{ emailTaken: true }) onullsi todo está bien. - Como la función es
async, siempre devuelve una promesa, aunque elreturnparezca devolver un objeto. Eso es lo que Angular pide a un validador asíncrono: una promesa o un observable.
ValidationErrors es el tipo que trae Angular para ese objeto de error: un
objeto cuyas claves son los nombres de los errores.
¿Y el nombre de usuario?¶
El campo username también tiene un validador propio de «ya existe», pero es
síncrono (Módulo RF9). La
diferencia es de dónde sale la respuesta: el usuario «ocupado» es un valor fijo
que ya está en el código, así que se contesta al instante. El email se plantea
como una consulta al servidor, por eso es asíncrono. En una aplicación real los
dos serían asíncronos: ninguna lista de usuarios cabe en el navegador.
Dónde se registra¶
email: [
'',
[Validators.required, Validators.pattern(FormUtils.emailPattern)],
[FormUtils.checkingServerResponse],
],
El campo es un array con tres elementos, cada uno con su papel:
[ valor inicial, validadores normales, validadores asíncronos ]
Se pasa la función FormUtils.checkingServerResponse sin paréntesis: no la
ejecutamos nosotros, la ejecuta Angular cuando toca. Si se escribiera con
paréntesis, se llamaría una vez al construir el formulario y se le pasaría su
resultado, una promesa, en vez de la función.
Qué pasa mientras espera: PENDING¶
Un control tiene tres estados de validez, no dos:
| Estado | Significa |
|---|---|
VALID |
Pasó todos los validadores |
INVALID |
Algún validador falló |
PENDING |
Un validador asíncrono todavía no contestó |
Mientras espera, control.pending es true y control.errors sigue en null:
todavía no se sabe nada malo. Cuando llega la respuesta, el control pasa a
VALID o a INVALID. El formulario entero también está en PENDING mientras
uno de sus campos lo esté.
Por eso el panel de depuración de la página muestra «Form Status» y «Form
Pending». Escribe un email correcto y verás PENDING durante 2 segundos.
Cuándo se ejecuta¶
- Solo si los validadores normales pasan. Con el campo vacío o mal escrito,
falla
requiredopatterny el asíncrono ni se llama. No tiene sentido preguntarle al servidor por un email que ni siquiera tiene forma de email. - Cada vez que cambia el valor. Si el usuario sigue escribiendo mientras espera, Angular descarta la respuesta de la ejecución anterior: una respuesta lenta y vieja nunca pisa a una más nueva.
- Con un servidor de verdad, eso significa una petición por tecla. Para
preguntar solo cuando el usuario sale del campo, el control admite la opción
updateOn: 'blur'.
Mostrar el mensaje¶
El error del validador asíncrono se lee igual que cualquier otro. Solo hay que
enseñarle al switch de getControlError la clave nueva:
case 'emailTaken':
return FORM_EMAIL_TAKEN_ERROR
Y agregarla al tipo ControlValidationError en form.interfaces.ts, igual que
en el Módulo RF9. El texto
(«Email already exists») vive en form.constants.ts. La plantilla no cambia: el
mismo hasFieldError y getFieldError del campo email muestran el mensaje, y
solo aparece cuando el campo fue tocado.
Qué pasa al guardar¶
onSubmit() {
if (this.myForm.invalid) {
this.myForm.markAllAsTouched()
return
}
}
Ojo con un detalle: mientras el formulario está en PENDING, invalid es
false y valid también. Ninguno de los dos dice «está bien». Si alguien
pulsa «Create» durante esos 2 segundos, invalid es false y el código sigue
adelante como si fuera válido. Hoy no importa, porque onSubmit todavía no envía
nada; cuando envíe, hay que comprobar valid (o esperar a que termine), no solo
invalid.
Errores comunes¶
- Escribir
[FormUtils.checkingServerResponse()]con paréntesis: se pasa una promesa en vez de la función. - Ponerlo en la segunda posición del array del campo: ahí van los validadores normales. El asíncrono va en la tercera.
- Devolver un objeto suelto, sin promesa ni observable: Angular espera una promesa o un observable de un validador asíncrono y falla si recibe otra cosa. Un validador que responde al instante va en la segunda posición.
- Un observable que nunca termina: debe completarse después de dar la respuesta. Una petición HTTP ya lo hace sola.
- Comprobar solo
invalidantes de enviar: durantePENDINGesfalse. - Esperar que corra con el campo vacío: no corre mientras falle un validador normal.
Preguntas de entrevista¶
1. ¿Cuándo usas un validador asíncrono y cuándo uno normal?¶
Respuesta:
Uno normal cuando la respuesta sale de lo que ya tienes en el navegador: el formato de un email, un largo mínimo, dos campos iguales. Uno asíncrono cuando hay que preguntar a algo externo y la respuesta tarda: si un email o un nombre de usuario ya existe. Devuelve una promesa o un observable en vez del resultado.
2. ¿Qué es el estado PENDING?¶
Respuesta:
Es el estado de un control (o de un formulario) mientras un validador asíncrono
todavía no contestó. No es válido ni inválido: se sabe que falta una respuesta.
El formulario está en PENDING si cualquiera de sus campos lo está. Sirve, por
ejemplo, para mostrar un indicador de carga o para no dejar enviar.
3. ¿Por qué el validador asíncrono no se ejecuta si el campo está vacío?¶
Respuesta:
Porque Angular solo lo lanza cuando los validadores normales del campo pasan.
Con el campo vacío falla required, y preguntar al servidor sería una petición
inútil. Además evita mandar valores que ya se sabe que son inválidos.
4. ¿Qué pasa si el usuario escribe mientras la validación está esperando?¶
Respuesta:
Angular lanza el validador otra vez con el valor nuevo y descarta la respuesta de
la ejecución anterior. Así una respuesta lenta de un valor viejo no pisa el
resultado de uno más nuevo. Con un servidor real conviene limitar las peticiones,
por ejemplo con updateOn: 'blur', para no preguntar en cada tecla.
5. ¿Por qué comprobar invalid antes de enviar un formulario con validadores asíncronos es un riesgo?¶
Respuesta:
Porque durante PENDING el formulario no es inválido (invalid es false) ni
válido (valid es false). Si se comprueba solo invalid, un envío durante la
espera pasaría como si todo estuviera bien. Hay que comprobar valid, o esperar
a que el estado deje de ser PENDING.
Módulo RF11: Selectores anidados con datos del servidor¶
El problema que resuelve¶
Tienes tres listas desplegables que dependen una de otra: eliges un continente, y el segundo desplegable muestra solo los países de ese continente; eliges un país, y el tercero muestra los países con los que limita. Se llaman selectores anidados (nested selectors). Las listas no están escritas en el código: salen de una API, así que cada elección dispara una petición.
La pantalla está en /country (el componente CountryPageComponent y el
servicio CountryService). La API es REST Countries.
Las piezas¶
region --valueChanges--> GET /region/{region} --> coutriesByRegion (opciones de «country»)
country --valueChanges--> GET /codes.alpha_3/{code} --> códigos de frontera
+ un GET por cada código --> borders (opciones de «border»)
- El formulario guarda solo lo que el usuario eligió: un texto por desplegable.
- Las listas (
regions,coutriesByRegion,borders) son señales (signals). La plantilla las recorre con@fory se redibuja sola cuando una cambia. Son datos, no estado del formulario, por eso no viven dentro demyForm. - El servicio es el único que conoce las URL y la forma de la respuesta.
valueChangesavisa cuando el usuario elige algo. Es lo que une el formulario con el servicio.
El formulario y las listas¶
regions = signal<Region[]>(this.countryService.regions)
coutriesByRegion = signal<Country[]>([])
borders = signal<Country[]>([])
myForm = this.fb.group({
region: ['', Validators.required],
country: ['', Validators.required],
border: [''],
})
region y country son obligatorios. border no, porque hay países sin
fronteras (una isla). Region es un tipo de unión
('Africa' | 'Americas' | ...): TypeScript solo acepta esos seis nombres, así
que un error de escritura se ve al escribir y no como una respuesta vacía de
la API.
El servicio: pedir y traducir¶
getCountriesByRegion(region: Region): Observable<Country[]> {
if (!region) {
return of([] as Country[])
}
const url = `${this.apiUrl}/region/${region}`
return this.http
.get<RESTCountryResponse>(url)
.pipe(map((response) => response.data.objects.map((item) => CountryUtils.countryMapper(item))))
}
Tres ideas:
http.gettodavía no pide nada. Devuelve un Observable («algo a lo que te puedes suscribir para recibir un valor más tarde») y la petición sale cuando alguien se suscribe. El servicio solo la prepara; la página la lanza.- El
<T>no comprueba nada.get<RESTCountryResponse>le dice a TypeScript cómo es la respuesta, pero en ejecución no se verifica. Si la API cambia su forma, el error aparece más tarde y lejos de aquí. - La API no devuelve una lista. Devuelve
{ data: { objects: [...] } }, y cada país usa sus propios nombres de campo (names.common,codes.alpha_3). Si se escribierahttp.get<Country[]>(), TypeScript lo daría por bueno y el@forde la plantilla recibiría un objeto y fallaría (un@forno puede recorrer un objeto). Por eso hay un traductor (mapper):
export class CountryUtils {
static countryMapper(item: RESTCountryItem): Country {
return {
name: {
common: item.names.common,
official: item.names.official,
nativeName: item.names.native,
},
cca3: item.codes.alpha_3,
borders: item.borders ?? [],
}
}
}
Convierte la forma de la API (RESTCountryItem) en la que usa la app
(Country). Si la API cambia un nombre, es el único archivo que se toca. El
?? [] cubre los países sin fronteras: el campo puede venir ausente, y así el
resto del código siempre puede recorrer una lista. Es la misma idea del
Módulo C11.
La plantilla usa country.name.common (el nombre ya traducido) y envía
country.cca3 como valor: el código de tres letras (CAN, USA) es lo que
después le pedimos a la API.
La clave no viaja al navegador¶
El servicio llama a una URL relativa, /api/countries/.... Quien la reenvía a
la API real y le agrega la clave es un proxy del servidor de desarrollo
(proxy.conf.mjs), así la clave nunca llega al navegador. Para que ng serve
lo use, tiene que estar registrado en angular.json, en
serve.options.proxyConfig. Sin ese registro el servicio funciona, pero la
petición no llega a la API.
Eso solo ocurre con ng serve. En producción lo hace Nginx
(Módulo C10
y Módulo P5).
Además, HttpClient hay que ofrecerlo con provideHttpClient() en
app.config.ts; sin ese proveedor, inject(HttpClient) falla.
Reaccionar al elegir: valueChanges¶
onFormChanged = effect((onCleanup) => {
const regionSubscription = this.onRegionChanged()
const countrySubscription = this.onCountryChanged()
onCleanup(() => {
regionSubscription.unsubscribe()
countrySubscription.unsubscribe()
})
})
Cada control tiene valueChanges, un Observable que emite cada vez que cambia
su valor. Suscribirse a él es la manera de reaccionar a lo que el usuario
elige. El effect aporta onCleanup, que se ejecuta cuando el componente se
destruye: ahí cancelamos las suscripciones para que no sigan vivas después de
salir de la página. Este effect no lee ninguna señal, así que se ejecuta una
sola vez.
Cuando cambia el continente¶
onRegionChanged(): Subscription {
return this.myForm
.get('region')!
.valueChanges.pipe(
tap(() => this.myForm.get('country')!.reset()),
tap(() => this.myForm.get('border')!.reset()),
tap(() => {
this.borders.set([])
this.coutriesByRegion.set([])
}),
switchMap((region) => {
if (!region) {
return []
}
return this.countryService.getCountriesByRegion(region as Region)
}),
)
.subscribe((countries) => {
this.coutriesByRegion.set(countries)
})
}
Dos pasos: limpiar y pedir.
- Limpiar. Las elecciones anteriores ya no tienen sentido: se reinician los
dos desplegables de abajo y se vacían sus listas.
tapejecuta una acción de paso y deja el valor intacto. - Pedir.
switchMapcambia la petición anterior por la nueva. Si el usuario cambia de continente antes de que llegue la primera respuesta, la petición vieja se cancela y su respuesta nunca se usa. ConmergeMap, una respuesta lenta y vieja podría llegar la última y pisar la lista nueva.
Devolver [] dentro de switchMap (una lista vacía es una fuente válida) no
emite nada y termina, así que subscribe no se ejecuta cuando el usuario
vuelve a «-- Select a continent --».
La cascada baja sola¶
reset() también hace emitir a valueChanges (con null). Cuando
onRegionChanged reinicia country, ese cambio llega a onCountryChanged,
que limpia border y borders. No hay que avisar a mano de abajo en cadena:
cada selector reacciona al anterior.
Cuando cambia el país: dos pasos¶
onCountryChanged(): Subscription {
return this.myForm
.get('country')!
.valueChanges.pipe(
tap(() => this.myForm.get('border')!.reset()),
tap(() => this.borders.set([])),
filter((value) => !!value),
switchMap((countryCode) => {
if (!countryCode) {
return []
}
return this.countryService.getCountryByCode(countryCode as string)
}),
switchMap((country) => this.countryService.getCountryNamesByCodes(country.borders || [])),
)
.subscribe((borders) => {
this.borders.set(borders || [])
})
}
La API da las fronteras como códigos (['USA']), pero el desplegable
necesita nombres. Por eso hacen falta dos pasos encadenados:
- Pedir el país elegido para leer sus fronteras (primer
switchMap). - Pedir cada código para conseguir su nombre (segundo
switchMap, que recibe lo que respondió el primero).
filter corta la cadena cuando el valor está vacío (null tras un reset):
no hay nada que pedir.
Muchas peticiones, una respuesta¶
getCountryNamesByCodes(countryCodes: string[]): Observable<Country[] | null> {
if (!countryCodes || countryCodes.length === 0) {
return of([] as Country[])
}
const countriesRequest: Observable<Country>[] = []
countryCodes.forEach((code) => {
const req = this.getCountryByCode(code)
countriesRequest.push(req)
})
return combineLatest(countriesRequest)
}
Hace una petición por cada código de frontera. combineLatest espera a que
todas hayan respondido al menos una vez y entonces emite un arreglo con los
resultados, en el mismo orden. Cada petición HTTP responde una vez y termina,
así que aquí combineLatest se comporta como «espera a todas»; forkJoin
daría el mismo resultado.
El if del principio no es opcional: combineLatest([]) termina sin emitir
nada, y la página nunca recibiría respuesta para un país sin fronteras.
Una consecuencia práctica: un país con diez fronteras lanza diez peticiones. Con una API que acepte varios códigos en una sola petición, sería una.
La plantilla¶
<select formControlName="country" class="form-control">
<option value="">-- Select a country --</option>
@for (country of coutriesByRegion(); track country) {
<option [value]="country.cca3">
{{ country.name.common }}
</option>
}
</select>
- La primera opción tiene valor vacío: es el estado «nada elegido» y coincide
con el
''con el que empieza el control. - En el desplegable de fronteras, cuando la lista está vacía (sin país elegido, o un país sin fronteras), una opción desactivada dice «No borders available». Así el desplegable no queda vacío sin explicación.
Errores comunes¶
- Tipar
http.get<Country[]>()y esperar un arreglo: el tipo no convierte nada. Si la respuesta viene envuelta, hay que sacarla y traducirla. - No registrar el proxy en
angular.json: la petición a/api/countriesno llega a la API aunque el código esté bien. - Olvidar
provideHttpClient():HttpClientno se puede inyectar. - Suscribirse y no cancelar: la suscripción sigue viva después de salir de
la página. Aquí se cancela en
onCleanup. - Usar
mergeMapen lugar deswitchMap: una respuesta vieja puede pisar a una nueva. - No limpiar los desplegables de abajo al cambiar el de arriba: queda elegido un país que no pertenece al continente nuevo.
combineLatestcon una lista vacía: no emite nada y la pantalla se queda esperando.
Preguntas de entrevista¶
1. ¿Por qué http.get no hace la petición cuando lo llamas?¶
Respuesta:
Porque devuelve un Observable «frío» (cold): describe la petición, pero esta
sale solo cuando alguien se suscribe, y sale una vez por cada suscripción. Por
eso el servicio puede devolver el Observable sin saber quién lo va a usar, y la
página decide cuándo lanzarlo (con subscribe, con el pipe async o dentro de
un rxResource).
2. ¿Qué hace el <T> en http.get<T>()? ¿Valida la respuesta?¶
Respuesta:
No valida nada. Solo le dice a TypeScript cómo creemos que es la respuesta; en
ejecución llega lo que mande el servidor. Si la forma real no coincide, el error
aparece más tarde (por ejemplo, un @for que recibe un objeto). Por eso, cuando
la API tiene su propia forma, conviene un traductor (mapper) que construya el
tipo de la app a partir de lo que llegó.
3. ¿Por qué switchMap y no mergeMap al pedir los países de una región?¶
Respuesta:
Porque solo importa la última elección del usuario. switchMap cancela la
petición anterior cuando llega un valor nuevo, así que una respuesta lenta y
vieja nunca se usa. mergeMap dejaría correr todas las peticiones a la vez, y la
que responda última gana, aunque sea la de una elección antigua.
4. ¿Cómo se actualiza un desplegable cuando cambia el de arriba?¶
Respuesta:
Se escucha valueChanges del control de arriba. Al cambiar, se reinician los
controles de abajo y sus listas, y se pide al servidor la lista nueva. Como
reset() también hace emitir a valueChanges, el cambio baja en cadena: al
reiniciar country, se limpian también las fronteras.
5. ¿Por qué la lista de fronteras necesita dos pasos y varias peticiones?¶
Respuesta:
Porque la API devuelve las fronteras como códigos, no como nombres. Primero se
pide el país elegido para leer sus códigos, y luego se pide cada código para
obtener su nombre. combineLatest junta todas esas respuestas en un solo
arreglo. Es una petición por frontera; una API que aceptara varios códigos a la
vez la reduciría a una.
6. ¿Qué pasa si le pasas una lista vacía a combineLatest?¶
Respuesta:
Termina de inmediato sin emitir ningún valor. Si la página espera una respuesta
para actualizar la pantalla, nunca la recibe. Por eso se comprueba antes si hay
códigos y, si no, se devuelve of([]), que sí emite una lista vacía.
7. ¿Por qué se cancelan las suscripciones en onCleanup?¶
Respuesta:
Porque valueChanges no termina solo: mientras la suscripción esté viva sigue
escuchando aunque el usuario ya haya salido de la página, y mantiene en memoria
el componente que la creó. onCleanup se ejecuta al destruir el componente, y
ahí se llama a unsubscribe().