Saltar a contenido

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 FormGroup y FormControl y conectarlo a la plantilla con [formGroup] y formControlName (Módulo RF2), y escribir menos con FormBuilder y Validators (Módulo RF3).
  • Leer el estado. Mostrar valid, pristine, touched, value y errors, y entender por qué pasar un FormControl completo al pipe json falla (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 PENDING y por qué comprobar solo invalid antes 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 con combineLatest (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>
  • row es una fila; sus hijos col* se reparten el ancho.
  • col ocupa el espacio que sobra. col-12 col-sm-4 ocupa las 12 columnas (todo el ancho) en un celular y 4 de 12 desde la pantalla sm (576 px).
  • mt-5 es 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 routerLinkActive en 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, .card y .container, y chocan. En este proyecto angular.json solo carga styles.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

  • FormControl es un campo: guarda su valor y su estado.
  • FormGroup es 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):

  1. controls: un objeto cuyas claves son los nombres de los campos y cuyos valores son los FormControl. Es el único obligatorio.
  2. validatorOrOpts: validadores síncronos para todo el grupo (por ejemplo, «la contraseña y su repetición deben coincidir»), o un objeto de opciones con validators, asyncValidators y updateOn.
  3. 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 clave name dentro 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] y formControlName no 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 el FormControl, 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 ReactiveFormsModule en el componente: el FormBuilder crea los objetos, pero las directivas [formGroup] y formControlName vienen 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:

  • required no falla con el número 0: para Angular, 0 es un valor. Por eso inStorage empieza en 0 y es válido, mientras que price empieza en 0 y falla por min(10).
  • minLength no se queja de un campo vacío: eso le toca a required. Así, name tiene un solo error a la vez: required si está vacío, minlength si 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 required rechace el 0: no lo hace.
  • Olvidar ReactiveFormsModule porque ya se usa FormBuilder.

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 FormControl o un FormGroup completo a json: referencia circular. Usa una propiedad.
  • Confundir pristine con untouched: uno mira si cambió el valor; el otro, si el campo perdió el foco.
  • Esperar que myForm.errors traiga 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>
}
  • hasFieldError responde «¿hay que mostrar un error?»: hay errores y el campo fue tocado. El !! convierte el objeto de errores (o null) en true o false.
  • getFieldError traduce la clave del error a un texto. Recorre las claves de errors, 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 tipo ControlValidationError.

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 FormGroup propio) 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 formGroupName en la plantilla, su acceso a los errores y sus validadores. Un FormArray dentro de un FormGroup dentro 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 hasFieldError y getFieldError ya salieron del componente a la clase compartida FormUtils (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 FormGroup de myForm (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:

  1. Los elementos, con la misma forma abreviada de fb.group: [valor inicial, validadores]. Cada juego empieza con su valor y con required (un juego vacío es un error).
  2. 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 este div, los controles son los elementos de ese array.
  • Dentro de un formArrayName, formControlName recibe la posición (un número), no un nombre. Por eso va entre corchetes: [formControlName]="i".
  • let i = $index da 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)
}
  • push agrega un control al final y removeAt(i) quita el de la posición i. La plantilla se redibuja sola.
  • newFavorite es un FormControl suelto, fuera de myForm: es solo el cuadro donde se escribe el juego antes de pulsar «Add favorite». Si estuviera dentro de myForm, su required dejarí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 de formControlName.
  • 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:

  1. keydown: el navegador decide enviar el formulario. Aquí todavía se puede cancelar.
  2. submit: el formulario ya se envió y (ngSubmit) ejecutó onSubmit().
  3. 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 de myForm.
  • [formControlName]="i": los juegos de la lista, donde el «nombre» es la posición.
  • [formControl]="newFavorite": el cuadro de «agregar», que está fuera de myForm a 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)
}
  • onSubmit solo muestra lo que está mal (markAllAsTouched). No usa FormUtils.onSave porque su form.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 con clear() 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.
  • newFavorite está fuera de myForm, así que myForm.reset() no lo toca: se limpia aparte.

Errores comunes

  • Usar track $index en una lista de controles: tras clear() o un borrado los inputs quedan conectados a controles que ya no existen.
  • Escribir let i = index en vez de let i = $index.
  • Pasar la posición a [formControl] en vez de a [formControlName] dentro de un formArrayName.
  • Meter el cuadro de «agregar» dentro del formulario: su required bloquea 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 en keydown, así que preventDefault() 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.required en una casilla de términos: una casilla desmarcada vale false, y false cuenta como valor, así que pasa. Usa requiredTrue.
  • Poner formControlName distinto en cada radio: entonces son controles separados y se pueden marcar los dos. Un grupo de radios comparte el mismo formControlName.
  • Olvidar el value de los radios: sin él, el control no sabe qué guardar al marcar uno.
  • Esperar texto en un checkbox: su valor es true o false, 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 email es un validador asíncrono, que se explica en el Módulo RF10.
  • El campo username lleva, junto a required y pattern, 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.
  • name exige 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.pattern tambié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
  }
}
  • ValidationErrors es 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 devuelve null.
  • 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, y getControlError muestra 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
}
  • passwordMismatch y usernameTaken no son claves de Angular: son las que devuelven nuestros areFieldsEqual y checkForTakenValue.
  • custom es la salida para un validador propio cuyo mensaje decide la página: getFieldError(form, campo, 'mi mensaje') lo recibe en customError. Si no se pasa, devuelve null.
  • Las claves nuevas también se agregan al tipo ControlValidationError. Si falta ahí, TypeScript no las reconoce en el switch.
  • 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 en myForm.errors. Se pregunta por el grupo.
  • Escribir case 'custom' || 'pattern':. El || devuelve el primer valor verdadero, que es 'custom', así que el case solo reconoce 'custom'. Para varios valores se escriben dos case seguidos:
case 'custom':
case 'pattern':
  return customError ?? null
  • Un patrón sin ^ y $: Angular los agrega al validar, pero entonces el texto de requiredPattern ya no coincide con el de PATTERNS y patternTypeError cae en el mensaje genérico.
  • Usar Validators.pattern y no required: un campo vacío pasa el patrón (y también minLength). Por eso cada campo lleva required además.
  • Agregar una clave nueva al switch y olvidar el tipo ControlValidationError.

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
}
  • sleep devuelve 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 }) o null si todo está bien.
  • Como la función es async, siempre devuelve una promesa, aunque el return parezca 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 required o pattern y 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 invalid antes de enviar: durante PENDING es false.
  • 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 @for y se redibuja sola cuando una cambia. Son datos, no estado del formulario, por eso no viven dentro de myForm.
  • El servicio es el único que conoce las URL y la forma de la respuesta.
  • valueChanges avisa 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.get todaví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 escribiera http.get<Country[]>(), TypeScript lo daría por bueno y el @for de la plantilla recibiría un objeto y fallaría (un @for no 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.

  1. Limpiar. Las elecciones anteriores ya no tienen sentido: se reinician los dos desplegables de abajo y se vacían sus listas. tap ejecuta una acción de paso y deja el valor intacto.
  2. Pedir. switchMap cambia 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. Con mergeMap, 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:

  1. Pedir el país elegido para leer sus fronteras (primer switchMap).
  2. 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/countries no llega a la API aunque el código esté bien.
  • Olvidar provideHttpClient(): HttpClient no 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 mergeMap en lugar de switchMap: 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.
  • combineLatest con 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().