Saltar a contenido

Parte 4: Country App

Segunda aplicación completa: un buscador de países. Es una aplicación Angular 22 creada con ng new, con Tailwind CSS para las clases de utilidad y daisyUI para los componentes ya diseñados (botones, tarjetas, barras de navegación). Si la Parte 3 enseñó a pedir y mostrar datos, esta parte se concentra en cómo organizar una aplicación que crece: rutas por funcionalidad, un layout compartido, qué componentes son comunes y cuáles propios de cada pantalla, y piezas reutilizables que se comunican entre sí.

Proyecto: 04-country-app/. Sitio publicado: angular-country.reskyon.com. Ábrelo mientras lees: cada módulo describe algo que se puede ver ahí.

Esta página va creciendo: cada módulo nuevo se agrega al final. Por ahora cubre el armazón de la aplicación, los componentes de búsqueda y lista, la clave de la API detrás de un proxy, el servicio que pide los datos, resource y una búsqueda genérica con rxResource, la pantalla de detalle, la búsqueda mientras se escribe, un caché en el servicio, los botones de región y la búsqueda guardada en la URL.

Qué vas a aprender:

  • Estilos con componentes ya diseñados. Instalar Tailwind CSS y daisyUI (Módulo C1) y elegir y cambiar el tema mientras la aplicación corre (Módulo C2).
  • Rutas que escalan. Un archivo de rutas por funcionalidad con loadChildren (Módulo C3) y un layout con menú compartido y rutas hijas (Módulo C4).
  • Componentes bien ubicados. Qué va en shared/ y qué en cada funcionalidad (Módulo C5), iconos SVG enlazados con datos (Módulo C6), un menú dirigido por datos (Módulo C7) y enlaces internos y externos en ese menú (Módulo C8).
  • Piezas que se hablan. Una búsqueda y una lista reutilizables que se comunican con input() y output() (Módulo C9).
  • Datos reales de una API. Esconder la clave de REST Countries con un proxy (Módulo C10) y un servicio tipado con mapper, carga y error (Módulo C11), y la misma pantalla con la API resource (Módulo C12).
  • Una búsqueda que sirve para varias pantallas. Una función reutilizable con rxResource, y por qué Angular admite varios estilos asíncronos (Módulo C13).
  • Una pantalla de detalle. Recibir el código del país desde la URL con input(), pedirlo con rxResource y mostrarlo con daisyUI (Módulo C14).
  • Menos peticiones. Buscar mientras se escribe, con un effect que espera a que dejes de teclear (Módulo C15), y un caché con Map en el servicio para no repetir consultas (Módulo C16).
  • Opciones como botones. Una lista cerrada de regiones con un tipo de unión y @for, reutilizando la búsqueda genérica (Módulo C17).
  • Búsquedas que se pueden compartir. Guardar la búsqueda en la URL con ActivatedRoute y Router, para recargar o mandar el enlace (Módulo C18).

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 3: GIFs App.


Módulo C1: Instalar Tailwind CSS y daisyUI

El problema que resuelve

Con Tailwind CSS das estilo escribiendo clases pequeñas en el HTML (text-3xl font-bold). Es muy flexible, pero un botón bonito termina con diez clases repetidas en cada sitio donde aparece.

daisyUI resuelve eso: es un complemento (plugin) de Tailwind que agrega nombres de clase para componentes completos. En vez de diez clases, escribes una o dos:

<!-- Sólo Tailwind -->
<button class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700">
  Buscar
</button>

<!-- Tailwind con daisyUI -->
<button class="btn btn-primary">Buscar</button>

Las dos cosas conviven: puedes usar btn de daisyUI y ajustar con clases de Tailwind encima (btn btn-primary mt-4).

Qué se instala

Paquete Para qué sirve
tailwindcss El motor de clases de utilidad
@tailwindcss/postcss La pieza que conecta Tailwind con el compilador de Angular
postcss La herramienta que procesa el CSS y que usa la anterior
daisyui Las clases de componentes (btn, card, navbar...)
npm install tailwindcss @tailwindcss/postcss postcss
npm install -D daisyui@latest

daisyUI 5 necesita Tailwind 4: las versiones de este proyecto son compatibles. La guía oficial paso a paso está en daisyUI: install for Angular.

La configuración, en tres archivos

1. .postcssrc.json le dice a Angular que procese el CSS con Tailwind:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

2. src/tailwind.css importa Tailwind y activa daisyUI:

@import "tailwindcss";
@plugin "daisyui";

3. angular.json carga ese archivo antes de los estilos globales:

"styles": [
  "src/tailwind.css",
  "src/styles.scss"
]

Tailwind va en su propio archivo .css y no dentro de styles.scss, por la misma razón que se explica en el Módulo G1: Sass interpreta el @import por su cuenta y lo avisa como obsoleto.

Comprobar que funciona

  1. Arranca el servidor con npm start (o compila con npm run build). En la salida de la compilación aparece una línea con el nombre y la versión de daisyUI, señal de que el plugin se cargó.
  2. Escribe un componente de daisyUI en una plantilla:
<button class="btn btn-primary">Hola</button>
  1. Mira la página: debe verse un botón con estilo. Si sale un botón sin estilo, la clase no llegó al CSS (ver Errores comunes).

Tailwind y daisyUI sólo incluyen en el CSS final las clases que realmente aparecen en tus archivos. Por eso un proyecto recién creado, sin ningún btn en las plantillas, genera un CSS muy pequeño.

Temas

daisyUI trae temas de color listos, y se cambian con el atributo data-theme de un elemento (normalmente <html>). Cómo elegirlos, cuáles incluir y cómo cambiarlos mientras la app corre se explica en el Módulo C2.

El aviso del editor en @plugin

VS Code puede subrayar @plugin "daisyui" con un aviso de que es una regla CSS desconocida. Viene del validador de CSS del editor, que no conoce las directivas propias de Tailwind 4 (@plugin, @theme, @source). No afecta al build. La extensión Tailwind CSS IntelliSense lo quita y además autocompleta las clases de daisyUI.

Errores comunes

  • Meter @import "tailwindcss" dentro de styles.scss: Sass lo interpreta y avisa. Va en un .css aparte.
  • Poner tailwind.css después de styles.scss en angular.json: lo que se carga al final gana en un empate, y tus estilos globales propios quedarían por debajo de los de Tailwind.
  • Olvidar el .postcssrc.json: Angular no sabe que debe procesar el CSS con Tailwind y las clases no tienen efecto.
  • Usar una versión de daisyUI que no corresponde a la de Tailwind: daisyUI 5 funciona con Tailwind 4; con Tailwind 3 se configura de otra forma.
  • Cambiar .postcssrc.json o angular.json con el servidor encendido: hay que reiniciar npm start para que los lea.
  • Construir nombres de clase a trozos en el código ('btn-' + color): Tailwind busca el texto completo y no encuentra la clase. Se escriben enteras.

Resumen

Tailwind CSS da las clases pequeñas y daisyUI agrega, encima, las clases de componentes. Se instalan cuatro paquetes y se configuran tres archivos: .postcssrc.json (procesar con Tailwind), tailwind.css (@import y @plugin) y angular.json (cargarlo antes que los estilos propios). Sólo se incluyen en el CSS las clases que de verdad se usan.

Preguntas de entrevista

1. ¿Qué agrega daisyUI sobre Tailwind CSS?

Respuesta:

Tailwind ofrece clases de utilidad pequeñas (px-4, bg-blue-600). daisyUI es un plugin que agrega, encima, clases de componentes completos (btn, card, navbar) y temas de color. No reemplaza a Tailwind: se pueden combinar, y las clases de utilidad sirven para ajustar los componentes.

2. ¿Por qué @plugin "daisyui" sale subrayado en VS Code si el proyecto compila?

Respuesta:

Porque el validador de CSS del editor no conoce las directivas de Tailwind 4, y las marca como reglas desconocidas. Es un aviso del editor, no del compilador: el build funciona y el plugin se carga. La extensión Tailwind CSS IntelliSense lo resuelve.

3. ¿Por qué un proyecto sin ningún btn en las plantillas casi no genera CSS de daisyUI?

Respuesta:

Porque Tailwind busca en los archivos del proyecto los nombres de clase que se usan y sólo genera el CSS de esas. Si ninguna plantilla tiene btn, esas reglas no se incluyen. Por eso tampoco sirve armar nombres de clase por partes en el código ('btn-' + color): el texto completo debe aparecer escrito.

4. ¿Qué hace .postcssrc.json en un proyecto Angular con Tailwind?

Respuesta:

Registra el plugin @tailwindcss/postcss en el proceso de CSS de Angular. Sin ese archivo, Angular compila el CSS tal cual y las directivas de Tailwind (@import "tailwindcss", @plugin) no se convierten en estilos reales.


Módulo C2: Elegir y cambiar el tema de daisyUI

El problema que resuelve

Abre el sitio publicado: fondo oscuro, botones naranja. Eso es el tema sunset. Cambia una sola palabra en index.html (data-theme="nord") y toda la app cambia de colores sin tocar una clase. Un tema (theme) es eso: un conjunto de colores (fondo, texto, botones, bordes) con nombre. Los componentes de daisyUI (btn, card...) usan los colores del tema activo, y al cambiar el tema cambian todos a la vez.

Hay tres decisiones separadas, y conviene no mezclarlas:

Pregunta Dónde se decide
¿Qué temas existen en mi CSS? tailwind.css, en las opciones del plugin
¿Cuál se usa ahora? El atributo data-theme
¿Cómo lo cambia el usuario? Un poco de código (o un input especial)

Paso 1: elegir qué temas se generan

Por defecto el plugin incluye sólo dos: light (el predeterminado) y dark (el que se usa cuando el sistema del usuario está en modo oscuro). Para cambiarlo, pasas opciones al plugin:

@import "tailwindcss";
@plugin "daisyui" {
  themes: light --default, dark --prefersdark, sunset;
}

Cada elemento de la lista es un nombre de tema, con banderas opcionales:

Bandera Qué significa
--default Es el tema que se usa cuando nada indica otro
--prefersdark Es el que se usa si el sistema está en modo oscuro y la página no fija un tema
(ninguna) El tema está disponible, pero sólo se activa si se pide con data-theme

Otras formas válidas:

/* un tema como predeterminado y otro para el modo oscuro */
@plugin "daisyui" {
  themes: nord --default, abyss --prefersdark;
}

/* todos los temas */
@plugin "daisyui" {
  themes: all;
}

Sólo se generan los temas de la lista. Si el CSS no lleva un tema, pedirlo con data-theme no hace nada. Con themes: nord --default, abyss --prefersdark, el CSS final lleva nord y abyss, y no lleva light ni dark.

Paso 2: elegir el tema activo con data-theme

<!-- src/index.html -->
<html lang="en" data-theme="sunset">

Con ese atributo, toda la página usa sunset. Reglas útiles:

  • El atributo gana sobre --default y --prefersdark: si está, el modo oscuro del sistema deja de importar.
  • Funciona en cualquier elemento, no sólo en <html>. Una sección con data-theme="dark" se ve oscura dentro de una página clara.
  • Sin atributo, se aplican las banderas: --default, o --prefersdark si el sistema está en modo oscuro.

Los temas que trae daisyUI

light, dark, cupcake, bumblebee, emerald, corporate, synthwave, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset, caramellatte, abyss y silk. Se ven todos juntos en la página de temas de daisyUI.

Paso 3: cambiar el tema mientras la app corre

Cambiar de tema es cambiar el valor de data-theme. En Angular conviene un servicio pequeño que guarde la elección en una señal y la escriba en <html>. Este servicio no está en el proyecto: es la pieza que agregarías para un selector de tema. Usa una señal y un effect, que ya viste en la Parte 3.

import { DOCUMENT } from '@angular/common'
import { effect, inject, Injectable, signal } from '@angular/core'

const THEME_KEY = 'theme'

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private document = inject(DOCUMENT)

  theme = signal<string>(localStorage.getItem(THEME_KEY) ?? 'sunset')

  private applyTheme = effect(() => {
    const theme = this.theme()
    this.document.documentElement.setAttribute('data-theme', theme)
    localStorage.setItem(THEME_KEY, theme)
  })
}

Cómo funciona:

  1. theme es una señal con el tema actual. Arranca con el que haya guardado en localStorage, o con sunset si no hay ninguno.
  2. El effect se ejecuta cada vez que theme cambia: escribe el atributo en <html> y guarda la elección para la próxima visita.
  3. inject(DOCUMENT) es la forma de Angular de llegar al documento, en lugar de usar document directamente.

Y en cualquier componente:

themeService = inject(ThemeService)
<button class="btn" (click)="themeService.theme.set('nord')">Nord</button>
<button class="btn" (click)="themeService.theme.set('sunset')">Sunset</button>

Alternativa sin código: theme-controller

daisyUI incluye una clase, theme-controller, que hace el cambio solo con HTML. Un input con esa clase y el nombre del tema en value activa ese tema cuando está marcado:

<input type="radio" name="theme" class="btn theme-controller" aria-label="Nord" value="nord">
<input type="radio" name="theme" class="btn theme-controller" aria-label="Sunset" value="sunset">

Sirve para un prototipo, pero no recuerda la elección al recargar la página. Con el servicio sí se puede guardar.

Errores comunes

  • Pedir un tema que no está en la lista themes: el atributo no hace nada, porque ese CSS no se generó.
  • Olvidar --default: el plugin no sabe cuál usar cuando no hay data-theme y la página puede quedar con colores inesperados.
  • Un nombre mal escrito en data-theme o en la lista (synthwave, no synth-wave): no falla, simplemente no aplica.
  • Fijar data-theme y esperar que el modo oscuro del sistema lo cambie: el atributo tiene prioridad, así que el sistema deja de influir.
  • Reiniciar la app y perder la elección: si sólo la guardas en memoria, vuelve al predeterminado. Guárdala en el navegador (localStorage) para que siga ahí al recargar.
  • Cambiar tailwind.css con el servidor encendido: hay que reiniciar npm start para que lo lea.

Resumen

Elegir un tema son tres cosas: decidir cuáles se generan (opciones del plugin en tailwind.css, con --default y --prefersdark), cuál está activo (atributo data-theme, en <html> o en cualquier elemento) y cómo se cambia (un servicio con una señal y un effect que escribe el atributo, o theme-controller sin código). Un tema que no está en la lista no se puede activar.

Preguntas de entrevista

1. ¿Para qué sirven --default y --prefersdark en las opciones de daisyUI?

Respuesta:

--default marca el tema que se usa cuando la página no indica otro. --prefersdark marca el que se usa cuando el sistema operativo del usuario está en modo oscuro y la página no fija un tema con data-theme. Juntos permiten que la app respete la preferencia del sistema sin código.

2. Puse data-theme="nord" y no cambia nada. ¿Qué miro?

Respuesta:

Primero, que nord esté en la lista themes del plugin en tailwind.css: daisyUI sólo genera el CSS de los temas listados, y sin él el atributo no tiene efecto. Después, que el nombre esté bien escrito y que el servidor se haya reiniciado tras tocar tailwind.css.

3. ¿Por qué el servicio usa un effect y no escribe el atributo en el método que cambia el tema?

Respuesta:

Porque el effect reacciona al valor de la señal, sin importar quién lo cambió: un botón, el valor recuperado de localStorage al arrancar o cualquier otro componente. Así hay un solo punto donde se toca el DOM y guardar la elección no se puede olvidar. Es un efecto secundario, que es justo para lo que sirve effect (a diferencia de computed, que sólo calcula valores).

4. ¿Qué diferencia hay entre un tema elegido con data-theme y uno elegido con --prefersdark?

Respuesta:

--prefersdark depende del sistema del usuario y sólo se aplica si la página no fija un tema. data-theme es una orden explícita de la página y tiene prioridad: si está presente, el modo oscuro del sistema se ignora. Por eso una app con selector de tema suele fijar el atributo, y una que sólo quiere respetar al sistema deja que actúen las banderas.


Módulo C3: Rutas por funcionalidad con loadChildren

Este módulo está escrito como instructivo: al final hay una lista de pasos para organizar las rutas de otro proyecto de la misma forma.

El problema que resuelve

Al principio, todas las rutas viven en un solo archivo, app.routes.ts. Con dos o tres pantallas no pasa nada, pero a medida que la app crece ese archivo se llena de rutas de países, de usuarios, de pedidos... y cualquiera que lo abra tiene que leer todo para encontrar lo suyo.

La solución es que cada funcionalidad (feature) tenga su propio archivo de rutas, y que el archivo principal sólo diga "todo lo que empiece por country se resuelve allá".

Antes (ejemplo)                Después
app.routes.ts                  app.routes.ts          ← sólo lo general
 ├─ ''                          ├─ ''                  (inicio)
 ├─ country                     ├─ country  ──────┐    "esto lo resuelve
 ├─ country/by-capital          └─ **             │     otro archivo"
 ├─ country/by-region                              ▼
 ├─ country/by-name            country/country.routes.ts
 └─ **                          ├─ ''
                                ├─ by-capital
                                └─ by-region

Las piezas del proyecto

src/app/
├── app.routes.ts                       rutas generales (inicio, comodín)
├── app.html                            <router-outlet />
├── shared/pages/home-page/             pantalla de inicio
└── country/
    ├── country.routes.ts               rutas de la funcionalidad "country"
    └── pages/by-capital/               pantalla "por capital"

Paso 1: las rutas generales

// app.routes.ts
export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./shared/pages/home-page/home-page.component'),
  },
  {
    path: 'country',
    loadChildren: () => import('./country/country.routes'),
  },
  {
    path: '**',
    redirectTo: '',
  },
]
Ruta Qué hace
path: '' + loadComponent La pantalla de inicio. Se carga sólo cuando se visita (carga perezosa, lazy loading)
path: 'country' + loadChildren Delega: todo lo que empiece por /country lo resuelve otro archivo de rutas, que también se carga cuando hace falta
path: '**' + redirectTo: '' Comodín: cualquier dirección que no coincida con nada manda al inicio

El orden importa: Angular prueba las rutas de arriba hacia abajo y se queda con la primera que coincide. Por eso el comodín ** va siempre al final.

Paso 2: las rutas de la funcionalidad

// country/country.routes.ts
import { Routes } from '@angular/router'
import { ByCapitalComponent } from './pages/by-capital/by-capital.component'

export const countryRoutes: Routes = [
  {
    path: '',
    component: ByCapitalComponent,
  },
]

export default countryRoutes
  • Aquí path: '' significa "justo /country", porque este archivo ya está colgado de country: las rutas de un archivo hijo se suman a la ruta del padre.
  • En el proyecto, el arreglo todavía no lleva el tipo (countryRoutes = [...]); con : Routes el editor autocompleta y avisa de errores, por eso se muestra tipado.
  • El archivo termina con export default: es lo que permite escribir import('./country/country.routes') en el padre sin indicar el nombre de nada.

En el Módulo C4 esta ruta '' deja de mostrar una sola pantalla: pasa a ser un layout con rutas hijas y su propio comodín. Desde entonces, /country/otra ya no cae en el inicio sino en by-capital.

Cómo se arman las direcciones

/               →  app.routes: path ''                    → HomePage
/country        →  app.routes: 'country'
                      └─ country.routes: ''               → ByCapital
/country/otra   →  'country' coincide, pero no hay hija 'otra'
                      └─ cae en '**' de app.routes        → redirige a '/'
/lo-que-sea     →  '**'                                   → redirige a '/'

Prueba las direcciones: / muestra el inicio, /country la pantalla de capital, y tanto /lo-que-sea como /country/otra terminan en el inicio.

Paso 3: el lugar donde se dibuja

<!-- app.html -->
<router-outlet />

<router-outlet /> es el hueco donde Angular pone el componente de la ruta activa. Al cambiar de dirección, cambia lo que hay en ese hueco; el resto de la plantilla, si lo hubiera, no se toca.

export default y export class: cuándo cada uno

Archivo Cómo se carga Exportación
HomePageComponent loadComponent: () => import(...) (perezosa) export default class
country.routes.ts loadChildren: () => import(...) (perezosa) export default del arreglo
ByCapitalComponent import { ByCapitalComponent } from ... (normal) export class con nombre

La regla del proyecto: export default sólo para lo que se carga con import() desde una ruta. Lo que se usa con un import normal (como ByCapitalComponent, que ya viaja dentro del archivo de rutas) lleva export class con nombre.

Lo que se gana: carga perezosa

A esto se le llama carga perezosa (lazy loading). Al compilar, Angular genera un archivo separado por cada import() de una ruta:

Lazy chunk files    | Names
chunk-….js          | country-routes
chunk-….js          | home-page-component

El código de country no se descarga hasta que el usuario entra en /country. En una app grande, eso hace que la primera carga sea mucho más liviana.

Instructivo: organizar otra app así

  1. Crea una carpeta por funcionalidad (country/, users/...), con sus páginas dentro (pages/).
  2. En cada una, un archivo <feature>.routes.ts con un arreglo tipado como Routes, y export default de ese arreglo.
  3. En app.routes.ts, una entrada por funcionalidad: { path: 'country', loadChildren: () => import('./country/country.routes') }.
  4. Las pantallas sueltas que no pertenecen a una funcionalidad (inicio) van en shared/pages/ con loadComponent.
  5. El comodín ** con redirectTo va al final de app.routes.ts.
  6. En la plantilla raíz, un <router-outlet />.
  7. Para añadir una pantalla nueva a la funcionalidad, agregas una entrada en su *.routes.ts (por ejemplo { path: 'by-region', component: ... }) y no tocas app.routes.ts.

Errores comunes

  • Poner el comodín ** antes que otras rutas: como Angular se queda con la primera que coincide, todo lo que esté debajo deja de alcanzarse.
  • Escribir path: '/country' con barra inicial: Angular rechaza la configuración al arrancar. Los path van sin barra al principio.
  • Repetir el prefijo en la ruta hija: si el padre ya es country, la hija es path: 'by-capital', no path: 'country/by-capital'.
  • Olvidar el export default en el archivo de rutas: import('./...') no encuentra qué usar y la ruta falla al cargarse.
  • No tipar el arreglo como Routes: funciona, pero se pierde el autocompletado y el aviso de errores en las propiedades (pth en vez de path pasaría desapercibido).
  • Olvidar <router-outlet />: la ruta cambia en la barra de direcciones, pero no se dibuja nada.
  • Usar export default en componentes que no se cargan con import(): al importarlos con llaves en otra plantilla, Angular dice que el elemento no se conoce.

Resumen

Cada funcionalidad tiene su propio archivo de rutas, y app.routes.ts sólo la enlaza con loadChildren, de modo que el archivo principal se mantiene corto y el código de cada funcionalidad se descarga cuando se usa. Las rutas hijas se suman a la del padre ('' dentro de country es /country), el comodín va al final, y export default se usa sólo para lo que se carga con import() desde una ruta.

Preguntas de entrevista

1. ¿Qué diferencia hay entre loadComponent y loadChildren?

Respuesta:

loadComponent carga un componente de forma perezosa, para una sola pantalla. loadChildren carga un conjunto de rutas (con sus propias pantallas y subrutas) cuando se visita el prefijo indicado. En ambos casos el código no se descarga hasta que hace falta; la diferencia es el tamaño de lo que se delega.

2. ¿Qué significa path: '' dentro de country.routes.ts?

Respuesta:

Que la ruta coincide justo con el prefijo del padre: como el archivo está colgado de country, path: '' equivale a /country. Las rutas de un archivo hijo se suman a la del padre, así que no hay que repetir country en ellas.

3. ¿Por qué el comodín ** va al final?

Respuesta:

Porque el enrutador recorre las rutas en orden y se queda con la primera que coincide. ** coincide con cualquier dirección: si estuviera antes, taparía a todas las que están debajo y nunca se alcanzarían.

4. ¿Qué pasa con /country/otra si country.routes.ts sólo define ''?

Respuesta:

El prefijo country coincide, pero no hay ninguna hija para otra. El enrutador sigue buscando en el resto de las rutas de app.routes.ts y termina en el comodín **, que redirige al inicio: la dirección acaba mostrando la pantalla de inicio.

5. ¿Para qué sirve el export default en un archivo de rutas?

Respuesta:

Para poder escribir loadChildren: () => import('./country/country.routes') sin nombrar la exportación: Angular toma la exportación por defecto del módulo cargado. Sin ella habría que indicar el nombre con un .then(m => m.countryRoutes).

6. ¿Qué se gana al dividir las rutas por funcionalidad?

Respuesta:

Dos cosas: orden (cada funcionalidad tiene sus rutas en un solo sitio y app.routes.ts no crece) y rendimiento (con loadChildren, Angular genera un archivo aparte por funcionalidad y sólo lo descarga cuando el usuario entra, por lo que la carga inicial es menor).


Módulo C4: Un layout con rutas hijas

Este módulo está escrito como instructivo: al final hay una lista de pasos para repetirlo en otra funcionalidad.

El problema que resuelve

En el Módulo C3 cada ruta de country mostraba una sola pantalla. Pero todas las pantallas de esa funcionalidad (por capital, por país, por región) van a compartir lo mismo: un menú superior. Si cada página lo incluyera por su cuenta, habría que repetirlo en todas y cambiarlo en todas.

La solución es un layout: un componente que dibuja lo común y deja un hueco para la pantalla que cambia.

┌──────────────────────────────────────────┐
│  CountryLayoutComponent                  │
│  ┌────────────────────────────────────┐  │
│  │ <country-top-menu />   ← siempre   │  │
│  └────────────────────────────────────┘  │
│  ┌────────────────────────────────────┐  │
│  │ <router-outlet />                  │  │
│  │   by-capital  |  by-country | ...  │  │ ← cambia según la ruta
│  └────────────────────────────────────┘  │
└──────────────────────────────────────────┘

Paso 1: el layout

// country-layout.component.ts
@Component({
  selector: 'app-country-layout',
  imports: [RouterOutlet, TopMenuComponent],
  templateUrl: './country-layout.component.html',
})
export class CountryLayoutComponent {}
<!-- country-layout.component.html -->
<country-top-menu />
<section class="px-4 pb-5">
  <router-outlet />
</section>

Dos detalles importantes:

  • El layout tiene su propio <router-outlet />: es donde se dibujarán las pantallas hijas. El que está en app.html dibuja el layout; el del layout dibuja la pantalla.
  • Hay que importar RouterOutlet en el layout, y también el componente del menú, porque las plantillas sólo conocen lo que está en imports.

Paso 2: las rutas con children

// country.routes.ts
export const countryRoutes: Routes = [
  {
    path: '',
    component: CountryLayoutComponent,
    children: [
      {
        path: 'by-capital',
        component: ByCapitalComponent,
      },
      {
        path: '**',
        redirectTo: 'by-capital',
      },
    ],
  },
]

export default countryRoutes

La ruta del layout tiene component y children a la vez: es lo que la convierte en un layout. Su path: '' no añade nada a la dirección, de modo que las hijas cuelgan directamente de /country.

Dirección Qué dibuja
/country/by-capital El layout con ByCapitalComponent en su hueco
/country Cae en el comodín hijo y redirige a /country/by-capital
/country/lo-que-sea También redirige a /country/by-capital
/lo-que-sea Está fuera de country: cae en el comodín general y vuelve al inicio

Entra en /country y mira la barra de direcciones: termina en /country/by-capital, y en la página aparecen el layout, el menú y la pantalla de capital.

Dos comodines, dos alcances

Ahora hay dos **, y cada uno actúa en su nivel:

app.routes.ts      **  →  redirige al inicio ('')
country.routes.ts  **  →  redirige a 'by-capital'   (sólo dentro de /country)

El de la funcionalidad tiene prioridad dentro de /country, porque Angular entra primero en esa rama. Por eso, desde que existe el layout, /country/otra ya no cae en el inicio, como pasaba en el Módulo C3: cae en by-capital.

¿Por qué las hijas no usan loadComponent?

Las pantallas hijas se importan con un import normal y no de forma perezosa. La razón: el layout sólo se carga cuando se entra en country, y las pantallas de esa funcionalidad se necesitan justo en ese momento. Separarlas en archivos distintos no ahorra nada. La carga perezosa ya ocurre un nivel arriba, con loadChildren.

Por la misma regla del proyecto, CountryLayoutComponent y ByCapitalComponent llevan export class con nombre: se importan de forma normal en el archivo de rutas.

Instructivo: un layout para otra funcionalidad

  1. Crea el componente layout en <feature>/layouts/<nombre>-layout/.
  2. En su plantilla, pon lo común (menú, cabecera, barra lateral) y un <router-outlet /> para el contenido.
  3. Importa RouterOutlet y los componentes que use en el imports del layout.
  4. En <feature>.routes.ts, una ruta con path: '', component: <Layout> y children: [...] con las pantallas.
  5. Agrega un comodín ** como última hija, con redirectTo hacia la pantalla por defecto.
  6. Para una pantalla nueva, añades una entrada en children; el layout no lo tocas.

Errores comunes

  • Olvidar el <router-outlet /> dentro del layout: el menú se ve, pero las pantallas nunca aparecen.
  • Poner el comodín antes que las demás hijas: captura todo y las hijas siguientes no se alcanzan.
  • Escribir redirectTo: '/by-capital' con barra: es una ruta absoluta desde la raíz de la aplicación, no desde country. Sin barra es relativa a donde está la regla.
  • Repetir el menú en cada página: es justo lo que el layout evita.
  • Poner el layout como ruta sin children: funciona como una pantalla más, pero no tiene dónde dibujar las demás.

Resumen

Un layout es una ruta con component y children: el componente dibuja lo común y su <router-outlet /> dibuja la pantalla hija que corresponda. Así el menú se escribe una sola vez. El comodín de la funcionalidad actúa dentro de /country y el general queda para el resto.

Preguntas de entrevista

1. ¿Cuándo usarías un layout con rutas hijas?

Respuesta:

Cuando varias pantallas comparten una estructura (menú, cabecera, barra lateral) y sólo cambia el contenido. El layout se escribe una vez y las rutas hijas se dibujan en su <router-outlet />. Evita repetir esa estructura en cada página y cambiarla en varios sitios.

2. ¿Cuántos <router-outlet /> hay en esta aplicación y para qué sirve cada uno?

Respuesta:

Dos. El de app.html dibuja la ruta de primer nivel (inicio o el layout de country). El del layout dibuja la pantalla hija (by-capital...). Cada <router-outlet /> dibuja las rutas del nivel en el que está.

3. ¿Qué pasa al entrar en /country, si sólo hay una hija llamada by-capital?

Respuesta:

La ruta del layout (path: '') coincide, pero ninguna hija coincide con una dirección vacía; entonces actúa el comodín hijo, que redirige a by-capital. La dirección termina siendo /country/by-capital y se ve el layout con esa pantalla.

4. ¿Qué diferencia hay entre el comodín de app.routes.ts y el de country.routes.ts?

Respuesta:

El alcance. El de country.routes.ts sólo se evalúa dentro de /country y manda a la pantalla por defecto de esa funcionalidad; el de app.routes.ts atiende todo lo demás y manda al inicio. Angular entra primero en la rama que coincide con el prefijo, así que dentro de /country manda el de la funcionalidad.


Módulo C5: Componentes compartidos y de funcionalidad

El problema que resuelve

A medida que crecen las pantallas, aparecen piezas que se repiten: un pie de página, un menú. La pregunta es dónde viven y quién las puede usar. Si todo está en una carpeta común, no se sabe qué depende de qué.

La regla: compartido o de la funcionalidad

Carpeta Para qué Ejemplo Selector
shared/components/ Piezas que usa toda la aplicación footer app-footer
<feature>/components/ Piezas que sólo usa esa funcionalidad country/components/top-menu country-top-menu
src/app/
├── app.html                      usa  <app-footer />
├── shared/
│   ├── components/footer/        FooterComponent        (toda la app)
│   └── pages/home-page/
└── country/
    ├── components/top-menu/      TopMenuComponent       (sólo country)
    ├── layouts/country-layout/   usa  <country-top-menu />
    └── pages/by-capital/

La razón de separar: si mañana se borra o se mueve la funcionalidad country, todo lo que es suyo se va con ella y no deja piezas sueltas. Lo que está en shared se supone usado por varias partes.

El prefijo del selector

El prefijo del selector (app- o country-) indica de dónde es el componente cuando se lo ve en una plantilla. <country-top-menu /> dice que es del menú de country; <app-footer /> es de toda la aplicación. Es una convención del proyecto, no una obligación del framework.

Cómo se usa un componente

Un componente standalone (el que no necesita módulo, como todos los de este curso) se usa en dos pasos que ya viste en la Parte 2: lo importas en imports y escribes su etiqueta. Aquí lo nuevo es dónde va cada pieza.

// app.ts
@Component({
  selector: 'app-root',
  imports: [RouterOutlet, FooterComponent],
  templateUrl: './app.html',
})
export class App {}
<!-- app.html -->
<main class="m-auto flex grow flex-col min-h-screen">
  <router-outlet />
  <!-- mt-auto: allow the footer to stick to the bottom of the page -->
  <div class="mt-auto">
    <app-footer />
  </div>
</main>

Dos pasos y los dos hacen falta: importarlo en el imports del componente que lo usa y escribir su etiqueta en la plantilla. Si falta el primero, Angular avisa de que el elemento no se conoce.

Aquí el pie de página queda en app.html, fuera de las rutas: aparece en todas las pantallas (inicio y country) sin que cada una lo incluya.

Un detalle del pie de página: que se quede abajo

Cuando una pantalla tiene poco contenido (por ejemplo By Capital antes de buscar), el pie de página sube y deja un hueco vacío debajo. Lo normal es que siempre quede al fondo de la ventana. Se arregla en app.html con tres piezas:

Clase Qué hace
flex flex-col Pone los hijos del main uno debajo del otro
min-h-screen El main mide al menos el alto de la ventana
mt-auto El margen de arriba del pie de página se come todo el espacio libre

En un contenedor flex en columna, un margen automático reparte el espacio que sobra: mt-auto lo empuja todo hacia arriba del pie, y el pie queda pegado al fondo. Si el contenido es largo y no sobra espacio, el margen vale cero y el pie queda simplemente debajo del contenido.

La primera versión usaba flex-1 flex-grow en el div del pie. Eso hacía que el div se estirara para ocupar el espacio libre, pero el pie seguía arriba dentro de él: el hueco pasaba de estar encima a estar debajo. Para empujar un elemento al fondo se mueve el elemento (mt-auto), no se agranda su caja.

No se usa sticky ni fixed a propósito: esas clases dejan el pie flotando sobre la pantalla y tapando el contenido. Aquí queremos un pie normal, que sólo se queda abajo cuando sobra sitio. En Tailwind v4 la clase se llama grow; flex-grow es el nombre de la versión anterior.

Un detalle del pie de página: el año

// footer.component.ts
export class FooterComponent {
  currentYear = new Date().getFullYear()
}
<p>Copyright © {{ currentYear }} Reskyon - All rights reserved</p>

El año sale de una propiedad de la clase y se muestra con doble llave. Es un error típico al copiar ejemplos pensados para React escribir {new Date().getFullYear()} con llave simple: en Angular, la llave simple se interpreta como el inicio de una expresión especial y la plantilla no compila.

Un componente por responsabilidad

Componente Responsabilidad
App La raíz: outlet principal y pie de página
CountryLayoutComponent La estructura de la funcionalidad (menú + hueco)
TopMenuComponent El menú de navegación
ByCapitalComponent La pantalla de búsqueda por capital

Cada uno hace una cosa, y por eso se puede cambiar o reemplazar sin tocar los demás.

Instructivo: añadir un componente compartido

  1. Decide el alcance: si lo usará toda la app va en shared/components/<nombre>/; si es de una funcionalidad, en <feature>/components/<nombre>/.
  2. Crea el componente con un selector con el prefijo que corresponda (app-…, <feature>-…).
  3. Exporta la clase con nombre (export class): se importará en otro componente, no por una ruta.
  4. Impórtalo en el imports del componente que lo va a usar.
  5. Escribe la etiqueta en su plantilla.

Errores comunes

  • Escribir la etiqueta sin importar el componente: Angular dice que el elemento no se conoce.
  • Importar el componente y no usarlo: el compilador avisa de que el import sobra.
  • Meter en shared algo que sólo usa una funcionalidad: shared se vuelve un cajón de todo y se pierde la ventaja de separar.
  • Usar export default en estos componentes: el default es sólo para lo que se carga por una ruta con import().
  • Llave simple en la plantilla ({ expresión }): hay que usar doble llave ({{ expresión }}).
  • Estirar el contenedor del pie de página (flex-1) en vez de empujarlo (mt-auto): el hueco cambia de lugar, pero el pie no baja.

Resumen

Lo que usa toda la aplicación va en shared/; lo que usa una funcionalidad, en su propia carpeta components/. El prefijo del selector deja ver el origen. Un componente standalone se importa en el imports de quien lo usa y se escribe su etiqueta en la plantilla; el pie de página, al estar en app.html, aparece en todas las pantallas, y con min-h-screen más mt-auto se queda al fondo aunque haya poco contenido.

Preguntas de entrevista

1. ¿Cómo decides si un componente va en shared o en una funcionalidad?

Respuesta:

Por quién lo usa. Si lo necesitan varias partes de la aplicación (un pie de página, un botón), va en shared. Si sólo tiene sentido dentro de una funcionalidad (el menú de países), va con ella. Así, borrar la funcionalidad no deja componentes huérfanos y shared no se convierte en un cajón de todo.

2. ¿Qué hace falta para usar un componente standalone en una plantilla?

Respuesta:

Dos cosas: importar su clase en el imports del componente que lo usa, y escribir su selector como etiqueta en la plantilla. Con sólo la etiqueta, Angular no sabe qué componente es; con sólo el import, no se dibuja.

3. ¿Por qué el pie de página está en app.html y no dentro de cada pantalla?

Respuesta:

Porque debe verse en todas las pantallas. Al ponerlo en la plantilla raíz, fuera del <router-outlet />, se muestra siempre sin repetirlo ni olvidarlo en ninguna página.


Módulo C6: Iconos SVG con data binding

El problema que resuelve

Los iconos del menú son SVG escritos directamente en la plantilla. Se ven bien, pero tienen tamaño fijo (width="24" height="24") repetido en cada icono. Si mañana se quiere uno de 32 píxeles, hay que cambiar muchos sitios. Lo natural es guardar el tamaño en una variable de la clase y enlazarlo.

La regla: [attr.nombre]

En un SVG, los atributos los enlazas con [attr.nombre], no con [nombre]:

// top-menu.component.ts
export class TopMenuComponent {
  private readonly iconGrid = 24                       // cuadrícula del dibujo
  viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}`    // fijo: depende del dibujo
  iconSize = 18                                         // tamaño en pantalla
}
<svg
  xmlns="http://www.w3.org/2000/svg"
  [attr.width]="iconSize"
  [attr.height]="iconSize"
  [attr.viewBox]="viewBox"
>
  <path d="M0 0h24v24H0z" fill="none" />
  <path fill="none" stroke="currentColor" d="..." />
</svg>
Qué se enlaza Forma
Un atributo del SVG (width, height, viewBox, fill, d...) [attr.width]="valor"
Una clase [class.activo]="condicion" o [class]="expresion"
Un estilo [style.fill]="color"
Texto dentro del SVG {{ valor }}
Un evento (click)="metodo()"

Por qué no [width]

Con corchetes simples, Angular enlaza una propiedad del elemento en el DOM. En un <svg>, width no es una propiedad simple sino un objeto especial del navegador, y Angular no la reconoce: la plantilla no compila. [attr.width], en cambio, escribe directamente el atributo HTML, que es lo que se necesita.

El viewBox y el tamaño

Son dos cosas distintas, y mezclarlas es el error más común con iconos:

  • viewBox es el sistema de coordenadas del dibujo: la cuadrícula en la que se trazó el icono (0 0 24 24: un lienzo de 24 por 24 unidades).
  • width y height son el tamaño en pantalla: escalan ese lienzo.
viewBox  →  depende del DIBUJO                  (24: no cambia)
width    →  depende de DÓNDE SE MUESTRA         (iconSize: sí cambia)

El error: derivar el viewBox del tamaño

iconSize = 18
viewBox = `0 0 ${this.iconSize} ${this.iconSize}`   // 0 0 18 18  ✗

Parece lógico (el icono mide 18, el lienzo mide 18), pero el dibujo sigue trazado para 24 unidades. Con viewBox="0 0 18 18", las últimas seis unidades quedan fuera del lienzo y se recortan; y si el tamaño sube a 48, el viewBox pasa a 0 0 48 48 y el icono no crece: queda del mismo tamaño dentro de una caja mayor.

La solución: dos variables

export class TopMenuComponent {
  // La cuadrícula en la que se dibujaron los iconos. No cambia con el tamaño.
  private readonly iconGrid = 24
  viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}`

  // Tamaño en pantalla. Cambiar este valor redimensiona todos los iconos.
  iconSize = 18
}
<svg [attr.width]="iconSize" [attr.height]="iconSize" [attr.viewBox]="viewBox">

La plantilla no cambia: sigue enlazando las tres propiedades. Lo que cambia es de dónde sale cada una. Ahora, iconSize puede valer 16, 18 o 48 y el dibujo siempre se escala completo, sin recortes.

Qué pasa con cada combinación

Con el mismo icono, dibujado en una cuadrícula de 24:

Caja viewBox Dibujo resultante
24 px 0 0 24 24 El dibujo completo, a su tamaño (referencia)
48 px 0 0 24 24 (fijo) Se amplía al doble
48 px 0 0 48 48 (derivado) No crece: queda a 24 px dentro de una caja de 48
18 px 0 0 24 24 (solución) Escala 18/24, sin recortes

El orden de los campos de la clase

private readonly iconGrid = 24
viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}`   // usa iconGrid

Los campos de una clase se inicializan en orden. viewBox usa iconGrid, así que debe ir después. Si se escribieran al revés, iconGrid valdría undefined en ese momento y TypeScript lo marca.

Si iconSize pasa a ser una entrada (input()) o una señal, el viewBox no tiene por qué cambiar: sólo width y height leen la señal ([attr.width]="iconSize()"). Un computed para el viewBox sólo tiene sentido si lo que varía es la cuadrícula original del icono (16, 20 o 24), no el tamaño con que se muestra:

iconGrid = signal(24)                        // cuadrícula en la que se dibujó
iconSize = signal(32)                        // tamaño en pantalla
viewBox = computed(() => `0 0 ${this.iconGrid()} ${this.iconGrid()}`)

La regla para recordarlo: el viewBox depende del dibujo; width y height, de dónde se muestra.

Color: currentColor

<path fill="none" stroke="currentColor" d="..." />

currentColor significa "el color del texto de este elemento". El icono toma el color de su contenedor: dentro del menú de daisyUI cambia con el tema y con el estado (activo, hover) sin escribir ningún color.

De dónde salen los iconos

Los de este proyecto vienen de Iconify, un catálogo de conjuntos de iconos. Desde ahí se copia el SVG y se pega en la plantilla, o se importa como componente. Pegado en la plantilla es lo más sencillo, y permite este tipo de enlace.

Instructivo: un icono SVG con tamaño dinámico

  1. Copia el SVG con su viewBox original y déjalo así: es la cuadrícula del dibujo (iconGrid).
  2. En la clase, una propiedad con el tamaño en pantalla (iconSize = 18), distinta de la cuadrícula.
  3. En el <svg>, [attr.width]="iconSize" y [attr.height]="iconSize".
  4. En los trazos, stroke="currentColor" (o fill="currentColor") para heredar el color.
  5. Para algo que cambia según datos: [attr.fill], [attr.d], [class.…] o [style.…].
  6. Si se repite mucho, un componente icon con input() para el nombre y el tamaño.

En el Módulo C7 los trazos de los iconos pasan a un arreglo de datos y la plantilla genera cada opción con un @for, en lugar de repetir el <svg> por cada icono.

Errores comunes

  • [width] en vez de [attr.width]: la plantilla no compila.
  • Derivar el viewBox del tamaño (0 0 ${iconSize} ${iconSize}): con un tamaño menor que la cuadrícula recorta el icono, y con uno mayor no lo escala. El viewBox depende del dibujo; el tamaño es width y height.
  • Poner un color fijo en el trazo: el icono no sigue al tema. Usa currentColor.
  • Declarar viewBox antes que iconGrid: viewBox se calcula con iconGrid, y los campos se inicializan en orden; si iconGrid va después, todavía no tiene valor.
  • Olvidar xmlns: al pegar un SVG suelto, conviene conservar su xmlns="http://www.w3.org/2000/svg".

Resumen

En un SVG enlazas con [attr.nombre], porque Angular enlaza propiedades del DOM y las medidas del SVG son atributos. El tamaño se cambia con width y height, nunca con el viewBox, que define el lienzo. currentColor hace que el icono herede el color del texto y siga al tema.

Preguntas de entrevista

1. ¿Por qué [width] no funciona en un <svg> y [attr.width] sí?

Respuesta:

[width] enlaza una propiedad del elemento en el DOM, y en un <svg> esa propiedad no es un valor simple sino un objeto especial del navegador, que Angular no reconoce. [attr.width] enlaza el atributo, que es un texto, y eso sí se puede asignar. Es la regla general para cualquier atributo que no sea una propiedad.

2. ¿Cuál es la diferencia entre viewBox y width/height?

Respuesta:

viewBox define el sistema de coordenadas interno del dibujo (el lienzo); width y height definen el tamaño con que ese lienzo se muestra. Cambiando width y height el dibujo se escala sin deformarse; cambiar el viewBox cambia qué parte del lienzo se ve y puede recortar o desplazar el icono.

3. ¿Para qué sirve currentColor?

Respuesta:

Es un valor de CSS que equivale al color de texto del elemento. Usado en el stroke o fill de un SVG, el icono toma el mismo color que el texto que lo rodea, de modo que cambia con el tema, con el estado del botón o con una clase de color, sin definir colores en el SVG.


Módulo C7: Un menú dirigido por datos

Este módulo está escrito como instructivo: al final hay una lista de pasos para convertir cualquier lista repetida de la plantilla en datos.

El problema que resuelve

El menú del Módulo C5 tenía tres <li> casi idénticos. Cada uno repetía unas veinte líneas de HTML (la etiqueta <a>, el <svg>, sus atributos) y sólo cambiaban dos cosas: el trazo del icono y el texto:

<li>
  <a>
    <svg xmlns="http://www.w3.org/2000/svg" [attr.width]="iconSize" [attr.height]="iconSize" [attr.viewBox]="viewBox">
      <path d="M0 0h24v24H0z" fill="none" />
      <path fill="none" stroke="currentColor" d="M.5 15.5v-2.25S6 10.5 …" />  <!-- sólo cambia esto -->
    </svg>
    By Capital                                                                <!-- y esto -->
  </a>
</li>

Agregar una cuarta opción significaba copiar y pegar todo ese bloque otra vez.

La solución es separar lo que cambia (los datos) de lo que se repite (la estructura), y que la plantilla recorra los datos con un @for.

Antes                                   Después
<li> icono 1 + texto 1 </li>            datos:   [ {…}, {…}, {…}, {…} ]
<li> icono 2 + texto 2 </li>            plantilla: @for (opción de datos) { <li> … </li> }
<li> icono 3 + texto 3 </li>
(para una más: copiar otro bloque)      (para una más: agregar un objeto al arreglo)

Las piezas, cada una en su carpeta

src/app/
├── country/
│   ├── interfaces/top-menu.interfaces.ts  la FORMA de una opción  (TopMenuOption)
│   ├── constants/country.constants.ts     los DATOS               (menuOptions)
│   └── components/top-menu/               el componente que los muestra
└── shared/
    └── utils/strings.utils.ts             una función auxiliar    (hyphenatedToTitleCase)
Carpeta Qué guarda Por qué separado
interfaces/ Los tipos de TypeScript Se importan desde varios archivos sin traer datos
constants/ Valores fijos y listas Un solo sitio donde cambiar el contenido
shared/utils/ Funciones pequeñas que no guardan nada entre una llamada y otra (stateless) y sin relación con un tema concreto Se pueden probar y reutilizar solas, desde cualquier funcionalidad

Paso 1: la interfaz

// country/interfaces/top-menu.interfaces.ts
export type Target = '_self' | '_blank'

export interface TopMenuOption {
  label: string
  route: string
  // The "d" attribute of the icon path, drawn on a 24 x 24 grid
  iconPath: string
  target?: Target
}
  • label es el texto visible; route, adónde lleva; iconPath, el trazo del icono (sólo el atributo d: el resto del <svg> es igual en todos).
  • target? es opcional (el signo ?): las opciones internas no lo necesitan.
  • Target es una unión de literales: sólo admite exactamente esos dos textos. Si se escribe '_blank ' (con un espacio) o '_other', TypeScript lo rechaza al instante. Las uniones se explican en el Módulo 1 de TypeScript.

Paso 2: los datos, como constantes

// country/constants/country.constants.ts
import { hyphenatedToTitleCase } from '@utils/strings.utils'

export const BY_CAPITAL = 'by-capital'
export const BY_COUNTRY = 'by-country'
export const BY_REGION = 'by-region'
const WIKI = 'Wiki'
const URL_WIKI = 'https://angular-wiki.reskyon.com/04-country-app/'

export enum iconPaths {
  BY_CAPITAL = 'M.5 15.5v-2.25S6 10.5 …',
  BY_COUNTRY = 'M15.5 21.5v-17m0 17h-.333 …',
  BY_REGION = 'M23 12c0-6.075-4.925-11-11-11 …',
  WIKI = 'm12.165 7.835l1.3-1.3a3.536 …',
}

export const menuOptions: TopMenuOption[] = [
  {
    label: hyphenatedToTitleCase(BY_CAPITAL),
    route: BY_CAPITAL,
    iconPath: iconPaths.BY_CAPITAL,
    target: '_self',
  },
  // … by-country y by-region, con la misma forma …
  {
    label: WIKI,
    route: URL_WIKI,
    iconPath: iconPaths.WIKI,
    target: '_blank',
  },
]

Los trazos de los iconos son textos largos, así que viven en el enum iconPaths, en el mismo archivo: cada opción apunta a uno (iconPaths.BY_CAPITAL) en vez de llevar el trazo pegado en el objeto.

El import @utils/strings.utils es un alias (path alias): un nombre corto para la carpeta shared/utils/, declarado en tsconfig.json. Se explica en el Módulo C9.

Tres detalles de diseño:

  • Los textos van en constantes (BY_CAPITAL...): los escribes una sola vez y los reutilizas. Un error de tipeo en un texto repetido no da aviso; en una constante, sí.
  • La etiqueta se deriva de la ruta. En lugar de escribir 'By Capital' a mano, partes de 'by-capital' y la conviertes con una función (paso 3). Así ruta y etiqueta no pueden desincronizarse.
  • : TopMenuOption[] hace que TypeScript compruebe cada objeto contra la interfaz: si falta label o sobra una propiedad, lo marca.

Paso 3: la función auxiliar

// shared/utils/strings.utils.ts
export const hyphenatedToTitleCase = (text: string): string =>
  text
    .split('-')
    .filter((part) => part.length > 0)
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
    .join(' ')

Convierte una palabra con guiones en un título: by-capital pasa a By Capital y una palabra sin guion (singuion) sólo gana su mayúscula inicial (Singuion). Cómo funciona, paso a paso:

  1. split('-') parte el texto en trozos por cada guion.
  2. filter(...) descarta los trozos vacíos (guiones al principio, al final o repetidos).
  3. map(...) pone la primera letra en mayúscula y el resto en minúscula.
  4. join(' ') los une con un espacio.

No necesita un caso especial para las palabras sin guion: quedan en un solo trozo y el resto de pasos funciona igual.

Paso 4: el componente expone los datos

// top-menu.component.ts
import { menuOptions } from '../../constants/country.constants'
import { TopMenuOption } from '../../interfaces/top-menu.interfaces'

export class TopMenuComponent {
  menuOptions: TopMenuOption[] = menuOptions
  // … iconSize, viewBox …
}

La plantilla sólo ve lo que está en la clase del componente. Importar menuOptions en el archivo no basta: hay que asignarlo a una propiedad para que el HTML pueda usarlo.

Paso 5: la plantilla con @for

<ul class="menu bg-base-200 sm:menu-horizontal rounded-box w-full">
  @for (option of menuOptions; track option.route) {
    <li>
      <a>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          [attr.width]="iconSize"
          [attr.height]="iconSize"
          [attr.viewBox]="viewBox"
        >
          <path d="M0 0h24v24H0z" fill="none" />
          <path fill="none" stroke="currentColor" [attr.d]="option.iconPath" />
        </svg>

        {{ option.label }}
      </a>
    </li>
  }
</ul>
  • @for ... track option.route: track es obligatorio y le dice a Angular cómo reconocer cada elemento. Aquí la ruta sirve de identificador, porque es única.
  • [attr.d]="option.iconPath": el trazo del icono viene de los datos. Es el mismo [attr.nombre] del Módulo C6: en un SVG enlazas atributos, no propiedades.
  • {{ option.label }}: el texto, con doble llave.

El menú muestra cuatro opciones (By Capital, By Country, By Region y Wiki), cada icono con su trazo, con tamaño 18 y el viewBox fijo en 0 0 24 24.

Qué falta: que el enlace use route y target

Los datos ya traen route y target; lo que falta es que la etiqueta <a> los use, y ahí hay una trampa: una de las opciones (la Wiki) es un sitio externo y routerLink no sirve para eso. Cómo resolverlo, con la plantilla compartida y las dos ramas, está en el Módulo C8.

Las pantallas by-country y by-region se crean en el Módulo C9.

Instructivo: convertir una lista repetida en datos

  1. Identifica qué cambia entre las copias (aquí, el trazo y el texto) y qué se repite (todo lo demás).
  2. Crea una interfaz con lo que cambia (label, route, iconPath...). Lo que no siempre aparece, como opcional (target?).
  3. Para un valor que sólo admite unas pocas opciones, usa una unión de literales ('_self' | '_blank').
  4. Crea un arreglo tipado (: TopMenuOption[]) en un archivo de constantes. Los textos que se repiten, como constantes.
  5. Si un dato se puede derivar de otro (la etiqueta de la ruta), escribe una función en utils/ en vez de duplicarlo.
  6. En el componente, asigna el arreglo a una propiedad de la clase.
  7. En la plantilla, un @for con track sobre una propiedad única, y los enlaces de datos ({{ }}, [attr.…]) dentro.

Errores comunes

  • Importar el arreglo y no asignarlo a una propiedad del componente: la plantilla no lo ve.
  • Olvidar track en el @for: Angular exige indicar cómo identificar cada elemento.
  • [d] en vez de [attr.d]: en un SVG enlazas atributos.
  • Escribir target como texto libre (string) en lugar de una unión: un error de tipeo ('_blank ') pasa sin aviso.
  • Usar routerLink con una dirección externa: Angular la trata como una ruta interna y no navega al sitio.
  • Un track que no es único (por ejemplo, dos opciones con la misma ruta): Angular las confunde.
  • Repetir el texto y la etiqueta a mano: pueden quedar desincronizados; mejor derivar la etiqueta de la ruta.

Resumen

Para evitar un HTML repetido, se separan los datos de la estructura: una interfaz describe cada opción, un arreglo tipado en constants/ las contiene, una función en utils/ deriva lo que se puede derivar, el componente asigna el arreglo a una propiedad y la plantilla lo recorre con un @for. Agregar una opción pasa a ser agregar un objeto. Cómo enlazar cada opción, interna o externa, se ve en el Módulo C8.

Preguntas de entrevista

1. ¿Qué ventaja tiene un menú dirigido por datos frente a un HTML repetido?

Respuesta:

Separa lo que cambia de lo que se repite: la estructura se escribe una vez (el @for con su <li>) y el contenido vive en un arreglo. Agregar, quitar o reordenar opciones es tocar los datos, no copiar bloques de HTML, y TypeScript puede comprobar que cada opción tiene la forma correcta.

2. ¿Por qué Target es una unión de literales y no un string?

Respuesta:

Porque sólo hay dos valores válidos ('_self' y '_blank'). Con string cualquier texto se aceptaría, incluso uno con un error de tipeo, que fallaría en silencio en el navegador. Con la unión, TypeScript rechaza lo que no sea uno de los dos y el editor sugiere las opciones al escribir.

3. ¿Por qué la etiqueta se calcula con una función a partir de la ruta?

Respuesta:

Para tener un único origen: la ruta (by-capital) es el dato y la etiqueta (By Capital) se deriva de ella. Si la etiqueta se escribiera a mano, ruta y texto podrían desincronizarse al cambiar uno. La función, además, es pequeña y sin estado, así que se puede probar sola y reutilizar.

Respuesta:

routerLink para rutas de la aplicación: Angular navega sin recargar la página y resuelve rutas relativas. href para direcciones externas, donde el navegador debe cargar otro sitio; si se abre en otra pestaña (target="_blank") conviene añadir rel="noopener". Usar routerLink con una URL externa no funciona, porque se interpreta como una ruta interna.


Módulo C8: Enlaces internos y externos en un menú

Este módulo está escrito como instructivo: al final hay una lista de pasos para un menú que mezcla rutas de la app con direcciones de otros sitios.

El problema que resuelve

En el Módulo C7 el menú ya sale de un arreglo, y una de sus opciones es la Wiki, cuya dirección es un sitio externo (https://angular-wiki.reskyon.com/04-country-app/). La primera idea es usar routerLink para todas:

<a [routerLink]="option.route" [target]="option.target">

Con las rutas de la app funciona. Con la Wiki no. routerLink sólo conoce rutas de la aplicación, así que trata la dirección completa como una ruta relativa más. El href que genera para cada opción:

Opción href generado
By Capital /country/by-capital (bien)
Wiki /country/https:/angular-wiki.reskyon.com/04-country-app (mal)

Al hacer clic se abre otra pestaña de la propia app en esa ruta inventada; el comodín la manda a by-capital y nunca se llega a la wiki.

Un intento que parece lógico y no funciona

Un solo <a> que use routerLink o href según el caso:

<a
  [routerLink]="isExternal(option) ? null : option.route"
  [attr.href]="isExternal(option) ? option.route : null"
>

La opción externa queda sin href: routerLink también escribe el atributo href y su valor gana sobre el enlace propio. La salida es usar dos etiquetas <a> distintas, una para cada tipo.

La solución: dos ramas y una plantilla compartida

El problema de usar dos <a> es repetir el icono y el texto en las dos. Lo evitas guardando ese contenido en un <ng-template> y pidiendo a cada <a> que lo dibuje con NgTemplateOutlet.

// top-menu.component.ts
import { NgTemplateOutlet } from '@angular/common'
import { RouterLink, RouterLinkActive } from '@angular/router'

@Component({
  selector: 'country-top-menu',
  imports: [RouterLink, NgTemplateOutlet, RouterLinkActive],
  templateUrl: './top-menu.component.html',
})
export class TopMenuComponent {
  menuOptions: TopMenuOption[] = menuOptions

  // An absolute URL (http or https) is an external link, anything else is a route of this app.
  isExternal(option: TopMenuOption): boolean {
    return option.route.startsWith('http')
  }
}
<!-- top-menu.component.html -->
@for (option of menuOptions; track option.route) {
  <li>
    @if (isExternal(option)) {
      <a [attr.href]="option.route" [attr.target]="option.target" rel="noopener noreferrer">
        <ng-container
          [ngTemplateOutlet]="content"
          [ngTemplateOutletContext]="{ $implicit: option }"
        />
      </a>
    } @else {
      <a [routerLink]="option.route">
        <ng-container
          [ngTemplateOutlet]="content"
          [ngTemplateOutletContext]="{ $implicit: option }"
        />
      </a>
    }
  </li>
}

<ng-template #content let-option>
  <svg
    xmlns="http://www.w3.org/2000/svg"
    [attr.width]="iconSize"
    [attr.height]="iconSize"
    [attr.viewBox]="viewBox"
  >
    <path d="M0 0h24v24H0z" fill="none" />
    <path fill="none" stroke="currentColor" [attr.d]="option.iconPath" />
  </svg>

  {{ option.label }}
</ng-template>

Cómo funciona cada pieza

Pieza Qué hace
isExternal(option) Decide por la dirección: si empieza por http, es externa; si no, es una ruta de la app
@if … @else Dibuja una etiqueta <a> distinta según el caso
<ng-template #content> Guarda el contenido (icono y texto) sin dibujarlo: una plantilla es una receta, no un elemento
<ng-container> Un contenedor que no genera ningún elemento en el HTML; sólo sirve de lugar para algo
[ngTemplateOutlet]="content" Pide que se dibuje esa plantilla justo ahí
[ngTemplateOutletContext]="{ $implicit: option }" Le pasa datos: $implicit es el valor por defecto
let-option Recibe ese valor $implicit y lo llama option dentro de la plantilla

La razón de la plantilla no es que el contenido sea distinto en cada caso: el icono y el texto son idénticos. Lo que cambia es la etiqueta <a> que los envuelve. La plantilla compartida evita repetir el <svg>.

La receta y el plato: cómo se relacionan ng-template y ng-container

Una forma de recordarlo es pensar en una receta y un plato vacío:

<ng-template #content let-option>      →  la RECETA   (icono + texto; no se cocina sola)
<ng-container>                         →  el PLATO    (un lugar vacío, sin elemento propio)
ngTemplateOutlet                       →  "sirve ESTA receta en ESTE plato"
ngTemplateOutletContext                →  los INGREDIENTES que se le pasan a la receta
  1. <ng-template> es la receta. Guarda el icono y el texto, pero no dibuja nada por sí misma: sólo se dibuja cuando alguien lo pide. #content es su nombre (una variable de referencia de plantilla), y let-option es una variable que la receta recibe al dibujarse.
  2. <ng-container> es el plato vacío. No crea ningún elemento en el HTML: sólo marca un lugar donde se va a dibujar algo, como un marcador invisible.
  3. ngTemplateOutlet es la directiva que los une. Dice "dibuja esta plantilla aquí". Su valor es la receta: [ngTemplateOutlet]="content".
  4. ngTemplateOutletContext son los datos que se le entregan a la receta. Es un objeto, y cada clave se convierte en un valor que la plantilla puede leer.

Dónde falla la comparación: el plato no hace falta. ngTemplateOutlet es una directiva y la puedes poner en cualquier elemento real (un <span>, un <div>). Se usa <ng-container> sólo para no añadir una etiqueta de más.

Qué significa cada valor

<ng-container
  [ngTemplateOutlet]="content"
  [ngTemplateOutletContext]="{ $implicit: option }"
/>
Lo que se escribe Qué significa
[ngTemplateOutlet]="content" "Dibuja la plantilla que se llama content" (la del #content)
[ngTemplateOutletContext]="{ … }" "Y entrégale este objeto como datos"
$implicit: option La clave especial $implicit es el valor principal (el que va por defecto): aquí, la opción actual del menú
let-option (en el <ng-template>) Recibe el valor $implicit y lo llama option dentro de la plantilla

Dicho de forma sencilla: "dibuja la plantilla content aquí, dándole la opción actual".

Qué otros valores se les pueden dar

Los valores salen de la definición de Angular:

Directiva Valores que acepta Qué pasa
ngTemplateOutlet Una plantilla (content) La dibuja
null o undefined No dibuja nada: la opción quedó sin icono ni texto
ngTemplateOutletContext Un objeto con una sola clave $implicit Se lee con let-option
Un objeto con varias claves $implicit se lee con let-option; las demás, por su nombre
null o undefined La plantilla se dibuja sin datos

Un ejemplo con más de una clave:

<!-- al dibujar: se pasan dos datos -->
<ng-container
  [ngTemplateOutlet]="content"
  [ngTemplateOutletContext]="{ $implicit: option, index: $index }"
/>

<!-- en la plantilla: el principal y uno con nombre -->
<ng-template #content let-option let-position="index">
  {{ position }}-{{ option.label }}
</ng-template>

let-position="index" significa "llama position al valor de la clave index". Con eso, el menú muestra 0-By Capital, 1-By Country, 2-By Region.

También existe la forma abreviada, con asterisco, que dice lo mismo:

<ng-container *ngTemplateOutlet="content; context: { $implicit: option }" />

Por qué cada atributo del enlace externo

<a [attr.href]="option.route" [attr.target]="option.target" rel="noopener noreferrer">
  • href es la dirección: un enlace normal, que el navegador carga entero.
  • target viene de los datos: _blank abre otra pestaña. Si una opción no trae target, el enlace se abre en la misma.
  • rel="noopener noreferrer" protege al abrir una pestaña nueva: evita que la página abierta pueda controlar la ventana desde la que se abrió, y que se le envíe la dirección de origen.

Los enlazas con [attr.…] por la misma razón que en el Módulo C6: escribes el atributo del HTML directamente.

Cómo quedan los enlaces

Con esa plantilla, los cuatro enlaces quedan así:

Opción href target
By Capital, By Country, By Region /country/by-… (ninguno: misma pestaña)
Wiki https://angular-wiki.reskyon.com/04-country-app/ _blank, con rel="noopener noreferrer"

Cada enlace conserva su icono y su texto.

Alternativas para decidir si es externa

Opción Cómo Cuándo
Por la dirección (la usada) option.route.startsWith('http') Lo más corto; vale mientras todas las externas sean http(s)
Un campo en los datos external?: boolean en TopMenuOption Cuando se prefiere que sea explícito, sin deducirlo
Por target option.target === '_blank' No recomendada: una ruta interna también podría abrirse en otra pestaña

Dos detalles de la plantilla

  • El <ng-template #content> queda dentro del <ul>, pero no se dibuja: sólo se dibuja donde un ngTemplateOutlet lo pida.
  • let-option no tiene tipo: dentro de la plantilla, option es de tipo any. Es una limitación conocida de las plantillas con contexto.

Marcar la opción activa: routerLinkActive

Sólo la rama interna lleva una directiva más, para resaltar la opción de la pantalla en la que se está:

<a
  [routerLink]="option.route"
  class="btn"
  [routerLinkActive]="'btn-primary'"
  [routerLinkActiveOptions]="{ exact: true }"
>
Pieza Qué hace
[routerLinkActive]="'btn-primary'" Agrega esa clase cuando la ruta del enlace es la activa y la quita cuando no lo es
[routerLinkActiveOptions]="{ exact: true }" La clase sólo se pone si la dirección coincide exactamente, no si sólo empieza igual
class="btn" La clase que siempre tiene

El texto 'btn-primary' va entre comillas simples dentro de las comillas del atributo porque enlazas una expresión de TypeScript (un texto), no un nombre. Los enlaces externos no la llevan: nunca van a ser la ruta activa de la app.

En cada pantalla, sólo la opción de esa ruta lleva btn-primary.

Instructivo: un menú con enlaces internos y externos

  1. En los datos, cada opción trae route y, si hace falta, target.
  2. En la clase, un método isExternal(option) que decide si la dirección es externa.
  3. En la plantilla, un @if (isExternal(option)) { … } @else { … } con dos etiquetas <a>.
  4. En la externa, [attr.href], [attr.target] y rel="noopener noreferrer".
  5. En la interna, [routerLink], sin href.
  6. El contenido común (icono, texto) en un <ng-template #nombre let-x> al final del archivo, y en cada <a> un <ng-container [ngTemplateOutlet]="nombre" [ngTemplateOutletContext]="{ $implicit: x }" />.
  7. Importar RouterLink, RouterLinkActive y NgTemplateOutlet en el imports del componente.

Errores comunes

  • Usar routerLink con una dirección externa: se convierte en una ruta de la app y no lleva al sitio.
  • Un solo <a> con routerLink y [attr.href] condicionales: routerLink gana y el href externo desaparece.
  • Olvidar NgTemplateOutlet en imports: la plantilla compartida no se dibuja.
  • Olvidar $implicit en el contexto: let-option queda vacío y el icono y el texto salen en blanco.
  • Abrir en otra pestaña sin rel="noopener noreferrer": la página abierta puede acceder a la ventana de origen.
  • Repetir el <svg> en las dos ramas: funciona, pero obliga a cambiarlo en dos sitios.
  • Escribir un comentario // dentro de una etiqueta: en una plantilla no es un comentario. Los comentarios son <!-- así -->, y van fuera de la etiqueta. Un // entre los atributos se lee como atributos sueltos; puede compilar, pero no hace lo que se espera.

Resumen

routerLink sólo sirve para rutas de la app: una dirección externa necesita un <a> con href. Como no se puede combinar en una sola etiqueta, se usan dos ramas (@if … @else) y el contenido común va en un <ng-template> que cada rama dibuja con NgTemplateOutlet. El enlace externo lleva el target de los datos y rel="noopener noreferrer".

Preguntas de entrevista

Respuesta:

Porque routerLink navega dentro de la aplicación: convierte lo que recibe en una ruta del enrutador. Si recibe https://otro-sitio.com, la trata como una ruta relativa más y genera una dirección inexistente de la propia app. Para salir a otro sitio hace falta un <a> normal con href.

2. ¿Para qué sirve NgTemplateOutlet?

Respuesta:

Para dibujar una plantilla (<ng-template>) en el lugar que se indique, y poder reutilizarla en varios sitios o ramas. Aquí evita repetir el icono y el texto en la rama interna y en la externa. Se le pasan datos con el contexto, y dentro de la plantilla se leen con let-.

3. ¿Qué hace $implicit en el contexto de una plantilla?

Respuesta:

Es el valor por defecto del contexto. { $implicit: option } entrega option como valor implícito, y let-option (sin indicar nombre de propiedad) lo recibe. Si el contexto tuviera más propiedades, se leerían con let-nombre="propiedad".

4. ¿Por qué los enlaces externos llevan rel="noopener noreferrer"?

Respuesta:

Cuando un enlace abre una pestaña nueva, esa página podría acceder a la ventana que la abrió. noopener lo impide, y noreferrer evita además enviar la dirección de origen. Es una medida básica de seguridad al enlazar a sitios que no controlas.

5. ¿Qué diferencia hay entre <ng-template> y <ng-container>?

Respuesta:

<ng-template> guarda un trozo de plantilla sin dibujarlo: sólo se dibuja cuando alguien lo pide (por ejemplo, con ngTemplateOutlet). <ng-container> sí se procesa, pero no genera ningún elemento en el HTML: sirve para agrupar o para ser el lugar donde se dibuja algo, sin añadir una etiqueta de más.

6. ¿Qué valores aceptan ngTemplateOutlet y ngTemplateOutletContext?

Respuesta:

ngTemplateOutlet recibe la plantilla que debe dibujar y ngTemplateOutletContext los datos que le entrega. Si la plantilla es null, no dibuja nada. En el contexto, la clave $implicit se lee con un let-nombre a secas; cualquier otra clave, con let-otro="clave".


Módulo C9: Búsqueda y lista: componentes reutilizables que se hablan

Este módulo está escrito como instructivo: al final hay una lista de pasos para repetir el patrón en otras pantallas.

El problema que resuelve

Las tres pantallas de country (por capital, por país y por región) se parecen mucho: una caja de búsqueda arriba y una tabla de resultados abajo. Escribir ese HTML tres veces es el mismo problema del Módulo C7: al cambiar algo hay que cambiarlo en tres sitios.

La solución es partir esas piezas en componentes reutilizables y que cada pantalla sólo las componga.

src/app/country/
├── components/
│   ├── search/    country-search   la caja de búsqueda y su botón
│   └── list/      country-list     la tabla de resultados
└── pages/
    ├── by-capital/    usa  search + list
    ├── by-country/    usa  search + list
    └── by-region/     usa  list           (sin búsqueda hasta el Módulo C13)

Paso 1: las rutas de las nuevas pantallas

Las dos pantallas que faltaban entran como rutas hijas del layout (Módulo C4):

// country.routes.ts
children: [
  { path: 'by-capital', component: ByCapitalComponent },
  { path: 'by-country', component: ByCountryComponent },
  { path: 'by-region', component: ByRegionComponent },
  { path: '**', redirectTo: 'by-capital' },
],

Con esto, los enlaces del menú ya no caen en el comodín: cada opción lleva a su propia pantalla:

Dirección Opción activa del menú Caja de búsqueda Tabla
/country/by-capital By Capital Search By Capital Sí
/country/by-country By Country Search By Country Sí
/country/by-region By Region No tiene (la recibe en el Módulo C13) Sí

Paso 2: el componente de búsqueda, con entrada y salida

// components/search/search.component.ts
import { Component, input, output } from '@angular/core'

@Component({
  selector: 'country-search',
  templateUrl: './search.component.html',
})
export class SearchComponent {
  searchCriteria = input.required<string>()
  searchValue = output<string>()

  handleSearch(value: string) {
    this.searchValue.emit(value.trim())
  }
}
<!-- components/search/search.component.html -->
<section class="flex flex-row gap-2 my-2">
  <input
    type="text"
    class="input input-bordered mr-2 w-56"
    [placeholder]="`Search ${searchCriteria()}`"
    autofocus
    #capitalInput
    (keyup.enter)="handleSearch(capitalInput.value)"
  />
  <button class="btn btn-primary" (click)="handleSearch(capitalInput.value)">Search</button>
</section>

Un componente que se reutiliza necesita dos cosas: recibir datos de quien lo usa y avisar de lo que pasa dentro. Para eso están:

Pieza Para qué Se parece a...
input.required<string>() Recibe un dato del padre. Es obligatorio: si el padre no lo da, no compila El argumento de una función
output<string>() Avisa al padre de algo, con un valor Un aviso, o una función de respuesta (callback)
.emit(valor) Lanza ese aviso "Llamar a la función de respuesta"

Cómo funciona la búsqueda:

  1. #capitalInput es un nombre para la caja de texto dentro de la plantilla (una variable de referencia): así se lee su valor con capitalInput.value.
  2. Hay dos maneras de buscar y las dos llaman al mismo método: pulsar Enter en la caja ((keyup.enter)) o hacer clic en el botón ((click)). Más adelante se suma una tercera: buscar mientras se escribe (Módulo C15).
  3. handleSearch recorta los espacios de los lados con trim() y emite el valor limpio. Así quien lo reciba nunca tiene que limpiarlo.
  4. El texto de ayuda (placeholder) se arma con el criterio que llega por searchCriteria: Search By Capital, Search By Country...

Como placeholder es una propiedad de la caja de texto, la enlazas con [placeholder]. Es lo contrario del SVG del Módulo C6, donde hacía falta [attr.…].

Para más sobre input() y output(), ver el Módulo A4.

Cómo nombrar la salida y el método

La documentación de Angular da dos reglas distintas, y conviene no mezclarlas:

Regla Qué dice Dónde
Nombre de una salida Usar camelCase; evitar el prefijo on; evitar nombres que choquen con un evento del DOM Outputs → Choosing event names
Nombre de un método que atiende un evento Nombrarlo por lo que hace, no por el evento que lo dispara (saveUserData() mejor que handleClick()) Style guide → Name event handlers for what they do

Cómo queda en este módulo:

  • La salida, searchValue, cumple la primera regla: está en camelCase, no lleva on y no coincide con ningún evento del DOM. Por eso no se llama search, que sí es el nombre de un evento real de las cajas de búsqueda.
  • El método del hijo se llama handleSearch, con el prefijo handle que se usa en el proyecto para los métodos que atienden eventos.
  • En las pantallas, en este módulo el método se llama onSearch. Es el estado de C9: desde el Módulo C12 pasa a llamarse handleSearch en las tres pantallas, con la misma convención que el hijo.

La guía de estilo prefiere un nombre que diga la acción (por ejemplo, emitSearch en el hijo, o searchByCountry en la pantalla) y admite un nombre genérico con handle… cuando la lógica es larga y reparte el trabajo entre otros métodos. Lo importante es elegir una convención y mantenerla en todo el proyecto.

Paso 3: la pantalla compone las piezas

// pages/by-country/by-country.component.ts
import { hyphenatedToTitleCase } from '@utils/strings.utils'

@Component({
  selector: 'country-by-country',
  imports: [SearchComponent, ListComponent],
  templateUrl: './by-country.component.html',
})
export class ByCountryComponent {
  BY_COUNTRY = hyphenatedToTitleCase(BY_COUNTRY)

  onSearch(value: string) {
    console.log('Searching for country:', value)
  }
}
<!-- pages/by-country/by-country.component.html -->
<country-search [searchCriteria]="BY_COUNTRY" (searchValue)="onSearch($event)" />

<country-list />

Las dos líneas del medio son la conversación entre padre e hijo:

ByCountryComponent  ──  [searchCriteria]="BY_COUNTRY"  ──►  SearchComponent
   (el padre)            el dato BAJA                          (el hijo)

ByCountryComponent  ◄──  (searchValue)="onSearch($event)"  ──  SearchComponent
                          el aviso SUBE
  • [searchCriteria]="BY_COUNTRY" (corchetes) manda un dato hacia abajo, a la entrada del hijo. El valor 'By Country' sale de la constante de la ruta convertida con la función del Módulo C7.
  • (searchValue)="onSearch($event)" (paréntesis) escucha el aviso que viene hacia arriba. $event es lo que el hijo pasó a .emit(): el texto ya recortado.
  • imports: [SearchComponent, ListComponent]: como siempre, un componente sólo puede usar en su plantilla lo que está en su imports.

Escribe " peru " y pulsa Enter: la consola muestra Searching for country: peru. Escribe " chile " y haz clic en el botón: Searching for country: chile. En los dos casos, sin espacios.

Un detalle de nombres

La propiedad se llama igual que la constante importada (BY_COUNTRY = hyphenatedToTitleCase(BY_COUNTRY)). Funciona, porque el valor se calcula con la constante del archivo, pero confunde al leerlo: no se ve cuál es cuál. Un nombre distinto para la propiedad (criteria, por ejemplo) evita esa duda.

Paso 4: la lista, por ahora sólo la estructura

<!-- components/list/list.component.html (abreviado) -->
<table class="table table-md mt-2 w-full">
  <thead>
    <tr>
      <th>#</th><th>Icon</th><th>Flag</th><th>Name</th><th>Capital</th><th>Population</th><th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>#</th><td>Icon</td><td>Flag</td><td>Name</td><td>Capital</td><td>Population</td><td></td>
    </tr>
  </tbody>
</table>

Todavía es un esqueleto: una fila de ejemplo con los nombres de las columnas. El siguiente paso natural es que reciba la lista de países con un input() y la recorra con un @for, igual que el menú del Módulo C7.

Un alias para las utilidades: @utils

La función hyphenatedToTitleCase vive en shared/utils/strings.utils.ts, porque convertir texto no es algo propio de los países y puede servir en cualquier parte. Para no escribir rutas largas desde cada pantalla, tsconfig.json declara un alias (path alias), un nombre corto para esa carpeta:

"paths": {
  "@utils/*": ["./src/app/shared/utils/*"]
}
// antes (ruta relativa)
import { hyphenatedToTitleCase } from '../../../shared/utils/strings.utils'

// ahora (alias)
import { hyphenatedToTitleCase } from '@utils/strings.utils'

Los alias se explican con calma en el Módulo G5.

Instructivo: una pantalla de búsqueda con piezas reutilizables

  1. Crea el componente de búsqueda con una entrada obligatoria (input.required<string>()) para lo que cambia (el criterio) y una salida (output<string>()) para avisar del resultado.
  2. En su plantilla, una caja con variable de referencia (#nombre) y un botón; los dos llaman al mismo método ((keyup.enter) y (click)).
  3. En ese método, limpia el valor (trim()) y emítelo con .emit(...).
  4. Crea el componente de lista aparte, también reutilizable.
  5. En cada pantalla, importa los dos en imports y escribe sus etiquetas.
  6. Pasa el dato hacia abajo con [entrada]="valor" y escucha el aviso con (salida)="metodo($event)".
  7. Agrega la ruta de la pantalla como hija del layout.
  8. Pon las funciones generales en shared/utils/ y un alias en tsconfig.json.

Errores comunes

  • No pasar la entrada obligatoria: input.required obliga al padre a darla; sin ella, la plantilla no compila.
  • Leer la entrada sin paréntesis: en el hijo se escribe searchCriteria(). Sin paréntesis se obtiene la señal, no su valor.
  • Ponerle on al nombre de una salida (onSearch = output<string>()): la documentación pide evitarlo. Los nombres con on se parecen a los de los eventos del DOM.
  • Llamar a la salida como un evento del DOM (search, click, change): se confunde con el evento nativo del elemento.
  • Emitir el valor sin limpiar: el padre recibiría los espacios y tendría que recortarlos en cada pantalla.
  • Olvidar $event en el padre: (searchValue)="onSearch()" llama al método sin el valor.
  • Corchetes por paréntesis, o al revés: [...] manda datos hacia abajo; (...) escucha avisos que suben.
  • Usar el componente sin ponerlo en imports: Angular no reconoce la etiqueta.
  • Repetir el HTML de la búsqueda en cada pantalla: es justo lo que los componentes evitan.
  • Poner un alias en tsconfig.json y no reiniciar el servidor: el editor puede seguir marcando error hasta que se reinicia.

Resumen

Las pantallas de country componen dos piezas reutilizables: una búsqueda con una entrada (searchCriteria) y una salida (searchValue), y una lista. El dato baja con corchetes y el aviso sube con paréntesis. La búsqueda limpia el valor antes de emitirlo, y las funciones generales viven en shared/utils/ con un alias.

Preguntas de entrevista

1. ¿Cómo se comunica un componente hijo con su padre en Angular?

Respuesta:

El padre le pasa datos al hijo con una entrada (input()), escrita con corchetes en la plantilla del padre. El hijo avisa al padre con una salida (output()) y .emit(valor), y el padre la escucha con paréntesis, (salida)="metodo($event)". Los datos bajan y los avisos suben.

2. ¿Qué diferencia hay entre input() e input.required()?

Respuesta:

input() es opcional (puede quedar sin valor o tener uno por defecto). input.required() obliga al padre a darla: si falta, Angular no compila. Se usa para datos sin los cuales el componente no tiene sentido, como el criterio de búsqueda.

3. ¿Para qué sirve $event en (searchValue)="onSearch($event)"?

Respuesta:

Es el valor que el hijo pasó a .emit(). Con $event el método del padre recibe ese valor; sin él se ejecutaría el método, pero sin saber qué se emitió.

4. ¿Por qué handleSearch hace trim() antes de emitir?

Respuesta:

Para que cualquiera que use el componente reciba un valor limpio, sin espacios al principio ni al final. Si no, cada pantalla tendría que repetir esa limpieza y bastaría olvidarla en una para que las búsquedas fallen por un espacio.

5. ¿Por qué se mueve una función a shared/utils/ y se le crea un alias?

Respuesta:

Porque una función que no depende de un tema (convertir texto) no pertenece a una funcionalidad concreta y puede servir en todas. El alias de tsconfig.json (@utils/*) evita rutas relativas largas y frágiles, que se rompen al mover archivos.

6. ¿Por qué se recomienda no usar el prefijo on en el nombre de una salida?

Respuesta:

Porque un nombre con on parece un evento del navegador (onclick), y una salida no lo es. Las salidas se escuchan con los mismos paréntesis que los eventos del DOM, así que un nombre como searchValue deja claro que es tuya. Por la misma razón no conviene que coincida con un evento real (click, search). Esta regla es para el nombre de la salida; los métodos se nombran por lo que hacen.

Módulo C10: La clave de REST Countries detrás de un proxy

Este módulo está escrito como instructivo: al final hay una lista de pasos para repetir el patrón en otro proyecto. La teoría general de las claves está en el Módulo P5; el caso de la Parte 3 está en el Módulo G15. Aquí se aplica lo mismo a otra API.

El problema que resuelve

La API de REST Countries pide una clave en cada petición, en la cabecera Authorization: Bearer <clave>. Si Angular la manda, cualquiera la ve en la pestaña Red del navegador. Y la API tampoco deja que el navegador la llame directo: bloquea las peticiones de orígenes que no están autorizados (CORS).

La solución es la misma de la Parte 3: la aplicación llama a una URL propia (/api/countries) y un proxy en el servidor agrega la clave y reenvía la petición. La clave nunca llega al navegador.

navegador  ──►  /api/countries/...  ──►  proxy (ng serve)  ──►  api.restcountries.com
                (sin clave)               agrega Bearer           (con clave)

Paso 1: el proxy de desarrollo

04-country-app/proxy.conf.mjs lee la clave de .env (o de una variable de entorno) y la pone en la cabecera:

import { existsSync, readFileSync } from 'node:fs'
import { parseEnv } from 'node:util'

const env = existsSync('.env') ? parseEnv(readFileSync('.env', 'utf8')) : {}
const apiKey = process.env.REST_COUNTRIES_API_KEY ?? env.REST_COUNTRIES_API_KEY

if (!apiKey) {
  console.error('[proxy] REST_COUNTRIES_API_KEY is missing. Copy .env.template to .env and fill it in.')
  process.exit(1)
}

export default {
  '/api/countries': {
    target: 'https://api.restcountries.com',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api\/countries/, '/countries/v5'),
    headers: { Authorization: `Bearer ${apiKey}` },
  },
}
  • rewrite cambia el prefijo: /api/countries/capitals/Tokyo llega a la API como /countries/v5/capitals/Tokyo.
  • headers es lo que agrega la clave. Si la clave falta, el servidor ni arranca y dice por qué.

Paso 2: registrarlo en angular.json

"serve": {
  "options": { "proxyConfig": "proxy.conf.mjs" }
}

Paso 3: la URL relativa en el entorno

// src/environments/environment.ts
export const environment = {
  restCountriesUrl: '/api/countries',
}

El servicio usa environment.restCountriesUrl y no sabe nada de claves.

¿Y un interceptor?

Un interceptor HTTP sirve para agregar cabeceras a todas las peticiones, pero corre en el navegador. Todo lo que agrega se ve en la pestaña Red, así que no puede esconder una clave. Sirve para cosas que no son secretas (una cabecera de trazabilidad, registrar errores). Para la clave hace falta el proxy.

Cómo probarlo sin gastar la clave real

  1. Arrancar con la clave de demostración de la documentación de la API: la búsqueda responde 200 con datos de ejemplo.
  2. Arrancar con una clave inventada: responde 401. Eso prueba que el proxy sí manda la clave y que la API la está evaluando.
  3. Abrir la pestaña Red: la petición del navegador no lleva Authorization.

Instructivo: esconder la clave de otra API

  1. Poner la clave en .env (ignorado por git) y su nombre, sin valor, en .env.template.
  2. Copiar proxy.conf.mjs y ajustar target, rewrite y el nombre de la variable.
  3. Registrar proxyConfig en angular.json.
  4. Poner una URL relativa en el entorno.
  5. Reiniciar ng serve.
  6. Probar con una clave inventada: debe dar 401.
  7. Para producción, repetir lo mismo en el servidor. En Nginx es un bloque location ^~ /api/countries con rewrite, proxy_pass y proxy_set_header Authorization "Bearer ${REST_COUNTRIES_API_KEY}". La imagen de Nginx rellena esa variable al arrancar, así que en Coolify va como variable de ejecución, sin Available at Buildtime. Es la misma receta de la Parte 3 (Módulo G15).

Errores comunes

  • Unexpected token '<' con estado 200. El proxy no está activo y Angular devolvió su index.html. Causa típica: ng serve se arrancó antes de agregar proxyConfig. Solución: reiniciarlo.
  • Poner la clave en environment.ts. Queda dentro del JavaScript publicado.
  • Esperar que el proxy funcione en producción. proxy.conf.mjs sólo lo usa ng serve. Al publicar, otro componente tiene que hacer ese trabajo: en 04-country-app es Nginx (nginx.conf.template). Sin él, la aplicación publicada recibe el index.html en lugar de los datos.

Resumen

  • La clave vive en el servidor; el navegador sólo conoce una URL propia.
  • El proxy agrega Authorization: Bearer y reescribe el prefijo.
  • Un interceptor corre en el navegador, así que no protege secretos.
  • Cambiar angular.json exige reiniciar ng serve.

Preguntas de entrevista

1. ¿Por qué un interceptor no puede esconder una clave de API?

Respuesta:

Porque se ejecuta en el navegador. Lo que agrega a la petición sale desde la máquina del usuario y se ve en la pestaña Red. Para que la clave no llegue al navegador, quien la agrega tiene que ser un servidor.

2. ¿Qué hace rewrite en la configuración del proxy?

Respuesta:

Cambia la ruta antes de reenviar la petición. Permite usar una URL corta y propia en la aplicación (/api/countries) y que la API real reciba su ruta completa (/countries/v5).

3. ¿Por qué hay que reiniciar ng serve al cambiar angular.json?

Respuesta:

Porque el servidor lee esa configuración una sola vez al arrancar. Si el proxy se agregó después, las rutas /api/... caen en el index.html de la SPA y el navegador recibe HTML donde esperaba JSON.

4. ¿Cómo compruebas que el proxy realmente manda la clave?

Respuesta:

Arrancando con una clave inventada: si la API responde 401, la petición llegó hasta ella con esa clave. Y en el navegador se confirma que la petición no lleva la cabecera Authorization.

Módulo C11: Un servicio tipado con mapper, carga y error

Este módulo está escrito como instructivo: al final hay una lista de pasos para repetir el patrón en otras pantallas.

El problema que resuelve

La API devuelve un objeto enorme (RESTCountry) y la pantalla sólo necesita unos pocos datos. Si los componentes leen la respuesta cruda, cada cambio de la API los rompe a todos. La solución: el servicio traduce la respuesta a un modelo propio y pequeño (Country), y el componente sólo conoce ese modelo.

API (RESTCountry)  ──►  CountryMapper  ──►  Country  ──►  componente  ──►  plantilla

Paso 1: dos interfaces, la de la API y la propia

En country.interfaces.ts, RESTCountryResponse (con data.objects, la lista de países) describe lo que manda la API y Country describe lo que usa la aplicación:

export interface Country {
  code: string
  flag: string
  language: string
  name: string
  capital: string
  population: number
  description: string
  government: string
  region: string
  link: string
  nameSpanish?: string
}

La tabla de este módulo usa code, flag, name (con nameSpanish al lado), capital, population, language y link. Los otros tres (description, government, region) los muestra la pantalla de detalle del Módulo C14. nameSpanish es opcional (?) porque sale de las traducciones, que se tipan en el Paso 5b.

Paso 2: el mapper

CountryMapper convierte uno a uno con fromCountry. El método que usa el servicio es mapToCountry: recibe la lista de la API (items) y aplica fromCountry a cada elemento. fromCountry y joinNames son auxiliares private:

private static fromCountry(item: RESTCountry): Country {
  return {
    code: item.codes.alpha_2,
    flag: item.flag.url_svg,
    language: CountryMapper.joinNames(item.languages),
    name: item.names.common,
    nameSpanish: item.names.translations.spa.common,
    capital: CountryMapper.joinNames(item.capitals),
    description: item.descriptions.long,
    government: item.government_type,
    region: `${item.region} ${item.subregion}`,
    population: item.population,
    link: item.links.wikipedia,
  }
}

static mapToCountry(items: RESTCountry[]): Country[] {
  return items.map(CountryMapper.fromCountry)
}

Paso 3: el servicio devuelve Country[]

searchByCapital(query: string) {
  query = query.trim().toLocaleLowerCase()
  const url = `${this.apiUrl}?${PROPERTY_CAPITAL}=${query}`
  return this.http
    .get<RESTCountryResponse>(url)
    .pipe(
      map((response) => CountryMapper.mapToCountry(response.data.objects)),
      catchError((err: HttpErrorResponse) => {
        const message = err.error?.errors?.[0]?.message ?? err.message
        return throwError(() => new Error(message))
      }),
    )
}
  • get<RESTCountryResponse> le dice a TypeScript la forma de la respuesta. Sin el tipo, get devuelve Observable<Object> y subscribe da un error de «no overload matches this call».
  • La respuesta es un objeto, no un arreglo; la lista está en data.objects.
  • map es el de RxJS: transforma cada respuesta antes de que llegue a quien se suscribe.

Paso 4: el componente guarda carga, error y resultado

isLoading = signal(false)
isError = signal<string | null>(null)
countries = signal<Country[]>([])

El método de búsqueda ignora las llamadas mientras carga, limpia el error y, al suscribirse, actualiza las señales. Sin subscribe no se hace ninguna petición: un Observable de HttpClient es perezoso.

Paso 5: traducir los errores en el servicio

Cuando la API falla, Angular entrega un HttpErrorResponse, y el cuerpo que mandó la API está en su propiedad error. Por eso el mensaje se lee en err.error.errors[0].message, no en err.errors[0].message. Como el cuerpo puede no ser JSON (por ejemplo, si cae el servidor), se usa ?. y se deja err.message como texto de reserva.

Esa traducción se hace una sola vez, en el servicio, con catchError, y se devuelve un Error con throwError(() => new Error(message)). Así el componente recibe el texto en error.message y no necesita saber nada de HTTP. (Si se lanzara un string suelto, la API resource del Módulo C12 lo envolvería en otro Error.) Con subscribe queda así:

this.countryService.searchByCapital(query).subscribe({
  next: (countries) => this.countries.set(countries),
  error: (err: Error) => {
    this.isError.set(err.message)
    this.isLoading.set(false)
    this.countries.set([])
  },
})

Una búsqueda vacía hace que la API responda 400 (?capitals= sin valor). Se evita con if (!query) return al empezar el método de búsqueda.

Paso 5b: tipar las traducciones del nombre

El nombre en español está en names.translations.spa.common. Las claves de translations son códigos de idioma y cambian según el país, así que el tipo tiene que dejar leer las que siempre vienen y aceptar las demás:

export interface Translation {
  common: string
  official: string
}

// K: claves que siempre vienen; el resto de idiomas es opcional.
export type KeyedRecord<K extends string, V> = Record<K, V> & Partial<Record<string, V>>

type LanguageCode = 'spa' | 'eng' | 'fra' | 'jpn'

export type Translations = KeyedRecord<LanguageCode, Translation>
  • Record<K, V> pide todas las claves de K. Si solo spa es seguro, K debe ser solo 'spa'; con la unión de arriba también se exigen eng, fra y jpn.
  • Partial<Record<string, V>> deja pasar cualquier otro idioma, que se lee con ?. (translations.deu?.common).
  • Hecho genérico, el mismo tipo sirve para otras respuestas con claves libres. Si la API no trajera una clave obligatoria, el error aparecería al ejecutar, no al compilar.

Paso 6: formatear la lista

La lista (ListComponent) recibe countries = input.required<Country[]>() y muestra la población con el pipe number:

{{ country.population | number: '1.0-0' }}

El argumento es mínimo de enteros . mínimo de decimales - máximo de decimales. Con '1.3-3' se imprimen siempre tres decimales y el resultado es 11.781.559,000; con '1.0-0' queda 11.781.559. Los puntos y la coma dependen del idioma, que se registra en app.config.ts:

registerLocaleData(localeEsAr)
// providers: { provide: LOCALE_ID, useValue: 'es-AR' }

Instructivo: una pantalla que consume una API

  1. Definir la interfaz de la API y la interfaz propia.
  2. Escribir el mapper con un método estático público.
  3. En el servicio: http.get<TipoDeRespuesta>(url).pipe(map(...)).
  4. En el componente: tres señales (isLoading, isError, resultado).
  5. Suscribirse con next y error; apagar isLoading en los dos.
  6. Leer el mensaje del error desde error.error.
  7. Pasar el resultado al componente de lista con input.required().

Errores comunes

  • Olvidar el tipo en get. TypeScript infiere Object y falla al suscribir.
  • Tratar la respuesta como arreglo. Si es un objeto, lo que existe es response.data, no response[0].
  • Ver ,000 en un número. El argumento de number pide tres decimales.
  • Leer el mensaje en err.errors. El cuerpo está dentro de err.error.
  • Tipar el error del componente como HttpErrorResponse cuando el servicio ya lo tradujo. Lo que llega es un Error cuyo message es el texto.
  • Tipar translations como { [key: string]: string }. Cada idioma es un objeto con common y official, no un texto.

Resumen

  • El servicio devuelve el modelo propio, no la respuesta de la API.
  • get<Tipo>() tipa la respuesta; map la traduce.
  • El componente guarda carga, error y datos en señales.
  • El cuerpo de un error HTTP está en HttpErrorResponse.error; el servicio lo traduce a un Error cuyo message es el texto, con catchError y throwError.
  • Un tipo con una clave obligatoria y otras libres: Record<K, V> & Partial<Record<string, V>>.

Preguntas de entrevista

1. ¿Por qué convertir la respuesta de la API a un modelo propio?

Respuesta:

Para que los componentes dependan de lo que la aplicación necesita y no de la forma de la API. Si la API cambia, se arregla el mapper en un único lugar.

2. ¿Qué pasa si no te suscribes a un Observable de HttpClient?

Respuesta:

No se hace la petición. Es perezoso: la solicitud sale al suscribirse, y se repite con cada suscripción.

3. ¿Dónde está el cuerpo de la respuesta cuando una petición HTTP falla?

Respuesta:

En la propiedad error del HttpErrorResponse. El resto del objeto trae el estado (status) y la URL. Hay que protegerse porque ese cuerpo puede no ser JSON.

4. ¿Qué significa '1.0-0' en el pipe number?

Respuesta:

Mínimo un dígito entero y ningún decimal, ni obligatorio ni permitido. El número se muestra sin decimales y con los separadores del idioma configurado.

5. ¿Por qué traducir el error en el servicio y no en el componente?

Respuesta:

Porque saber dónde está el mensaje dentro de un HttpErrorResponse es un detalle de HTTP. Si el servicio lo resuelve una vez con catchError, cada pantalla recibe un texto listo para mostrar y no repite esa lógica.

6. ¿Por qué el componente guarda isLoading y lo consulta al buscar?

Respuesta:

Para no lanzar peticiones superpuestas si el usuario pulsa varias veces, y para poder mostrar un indicador de carga desde la plantilla.

Módulo C12: Pedir los datos con resource

Este módulo está escrito como instructivo: al final hay una lista de pasos para repetir el patrón en otras pantallas. Reemplaza la forma manual del Módulo C11 (tres señales y un subscribe) por la API resource de Angular. El servicio casi no cambia. La documentación oficial está en Async reactivity with resources.

El problema que resuelve

En C11 el componente escribía a mano tres señales (isLoading, isError, resultado) y las actualizaba dentro de subscribe. Es mucho código repetido en cada pantalla. resource hace ese trabajo: recibe una señal de entrada, pide los datos cada vez que cambia y expone el resultado, el error y el estado de carga como señales.

query (señal)  ──►  params  ──►  loader  ──►  value() / error() / isLoading()

Paso 1: la señal de entrada y el recurso

query = signal('')

countryResource = resource({
  params: () => this.query() || undefined,
  loader: ({ params: query }) =>
    firstValueFrom(this.countryService.searchByCapital(query)).then((countries) => {
      if (!countries || countries.length === 0) {
        throw new Error(`${NO_COUNTRIES_FOUND_ERROR}: ${query}`)
      }
      return countries
    }),
})
  • params es una función reactiva: cada vez que cambia lo que devuelve, el loader se ejecuta de nuevo.
  • undefined en params deja el recurso inactivo (idle): no hace ninguna petición. Un texto vacío ('') no cuenta como inactivo, y por eso se escribe this.query() || undefined.
  • loader recibe params y devuelve una promesa. Tu servicio devuelve un Observable, así que firstValueFrom toma su primer valor y lo convierte en promesa.
  • Lo que el loader lance (throw) acaba en countryResource.error().
  • NO_COUNTRIES_FOUND_ERROR es una constante de country.constants.ts, junto a EMPTY_QUERY_ERROR: el mismo texto sirve en todas las pantallas.

En versiones anteriores de Angular esta opción se llamaba request, y el loader recibía { request }. En Angular 22 se llama params, y los tutoriales viejos fallan con «request does not exist».

Paso 2: el servicio lanza un Error de verdad

catchError((err: HttpErrorResponse) => {
  const message = err.error?.errors?.[0]?.message ?? err.message
  return throwError(() => new Error(message))
}),

Si se lanza un texto (throwError(() => message)), el recurso lo envuelve en otro Error y el mensaje original queda en .cause. Con new Error(message), error().message ya trae el texto listo.

Paso 3: la plantilla lee el recurso

<country-search [searchCriteria]="BY_CAPITAL" (searchValue)="handleSearch($event)" />

<country-list
  [countries]="countryResource.hasValue() ? countryResource.value() : []"
  [errorMessage]="emptyQueryError() ?? countryResource.error()?.message"
  [isLoading]="countryResource.isLoading()"
/>
  • value() lanza una excepción si el recurso está en error. Si se lee sin cuidado, Angular aborta toda la plantilla y no se ve ni el mensaje de error. Por eso se lee solo cuando hasValue() es true.
  • error() no lanza: se puede leer siempre.
  • isLoading() indica que hay una petición en curso. La lista lo recibe en su entrada isLoading y muestra el indicador de carga mientras es true.
  • errorMessage e isLoading son entradas opcionales de ListComponent (input<string | undefined>() e input<boolean>(false)); countries sigue siendo input.required.

Paso 4: la validación del campo vacío no es del recurso

Con params en undefined el recurso está inactivo, así que nunca ejecuta el loader y nunca puede fallar. Avisar de que el campo está vacío es una validación de la pantalla, no un error de la API. Se hace en el manejador:

emptyQueryError = signal<string | undefined>(undefined)

handleSearch(value: string) {
  this.emptyQueryError.set(value ? undefined : EMPTY_QUERY_ERROR)
  this.query.set(value)
}

En la plantilla, emptyQueryError() va primero con ??, antes del error del recurso. Funciona aunque se busque vacío dos veces seguidas, porque el mensaje no depende de que query cambie.

Paso 5: repetir la misma búsqueda

query es una señal: si se escribe dos veces la misma capital, el valor no cambia y el recurso no vuelve a ejecutarse. Para repetir la petición: countryResource.reload().

Instructivo: pasar una pantalla de subscribe a resource

  1. Dejar el servicio igual; solo asegurarse de que lance new Error(...).
  2. Crear una señal con lo que el usuario busca.
  3. Crear el resource: params devuelve esa señal (o undefined si está vacía) y el loader llama al servicio con firstValueFrom.
  4. Quitar isLoading, isError y la señal de resultados; usar isLoading(), error() y value() del recurso.
  5. En la plantilla, leer value() solo con hasValue().
  6. Manejar el campo vacío en el manejador, con su propia señal.
  7. Borrar el subscribe viejo (o dejarlo comentado como referencia).

Errores comunes

  • Usar request en vez de params. En Angular 22 no existe.
  • Leer value() sin hasValue(). Con el recurso en error lanza una excepción y la plantilla entera deja de dibujarse.
  • Devolver '' en params. El recurso no queda inactivo: ejecuta el loader al cargar la página y hace una petición vacía.
  • Esperar que el recurso avise del campo vacío. Inactivo, no corre nada y no falla; ese aviso va en el manejador.
  • Probar el error con la clave de demostración. La API le responde 200 a cualquier búsqueda; para ver el error usa una clave inventada (401).

Resumen

  • resource junta entrada, petición, resultado, error y carga en un solo objeto.
  • params decide cuándo se pide; undefined lo deja inactivo.
  • El loader devuelve una promesa; firstValueFrom adapta el Observable.
  • value() lanza en error: se lee con hasValue().
  • Las validaciones del formulario no son errores del recurso.

Preguntas de entrevista

1. ¿Qué hace resource y qué problema resuelve?

Respuesta:

Convierte una petición asíncrona en señales que dependen de otra señal. Cuando cambia lo que devuelve params, vuelve a ejecutar el loader, y expone value(), error() y isLoading(). Evita escribir a mano las señales de carga, error y resultado y el subscribe en cada pantalla.

2. ¿Cómo se deja un resource sin hacer peticiones?

Respuesta:

Haciendo que params devuelva undefined. El recurso queda inactivo (idle) y no ejecuta el loader. Un texto vacío no sirve: no es undefined, así que el loader sí corre.

3. ¿Por qué value() puede romper la plantilla?

Respuesta:

Porque cuando el recurso está en error, leer value() lanza una excepción. Si ocurre al dibujar la plantilla, Angular la aborta entera. Se evita comprobando hasValue() antes de leerlo; error() sí se puede leer siempre.

4. ¿Por qué se usa firstValueFrom dentro del loader?

Respuesta:

Porque el loader de resource espera una promesa y HttpClient devuelve un Observable. firstValueFrom espera el primer valor del observable y lo entrega como promesa. En una petición HTTP basta, porque emite una sola vez.

5. ¿Dónde se valida que el campo de búsqueda no esté vacío?

Respuesta:

En el manejador de la pantalla, no en el recurso. Con la consulta vacía el recurso está inactivo y no ejecuta nada, así que no puede fallar. Es una validación del formulario y se guarda en su propia señal.

Esta pantalla repite su resource en cada búsqueda. El Módulo C13 lo saca a una función reutilizable y cambia resource por rxResource.

Módulo C13: Una búsqueda genérica con rxResource

En el Módulo C12 la pantalla «por capital» pidió sus datos con resource. Este módulo hace dos cosas: saca ese código a una función que sirve para varias pantallas (capital, país, región) y cambia resource por rxResource, que encaja mejor con un servicio que ya devuelve un Observable. La documentación oficial está en Async reactivity with resources.

El problema que resuelve

Con C12, cada pantalla de búsqueda repite lo mismo: la señal de la consulta, el resource, el throw de «no se encontraron países», la señal del campo vacío y la lectura segura de value(). Cambia una sola cosa entre pantallas: por qué propiedad se busca (capital, nombre, región).

by-capital  ─┐
by-country  ─┼─►  createCountrySearch(propiedad)  ─►  countries() / errorMessage() / isLoading()
by-region   ─┘

Paso 1: la función createCountrySearch

export function createCountrySearch(searchBy: string) {
  const countryService = inject(CountryService)
  const query = signal('')
  const emptyQueryError = signal<string | undefined>(undefined)

  const countryResource = rxResource({
    params: () => query(),
    stream: ({ params: query }) => {
      if (!query) {
        return of([])
      }
      return countryService.searchByRequest(query, searchBy).pipe(
        delay(300), // Optional: a small delay so the loading indicator can be seen
        tap((countries) => {
          if (!countries || countries.length === 0) {
            throw new Error(`${NO_COUNTRIES_FOUND_ERROR}: ${query}`)
          }
        }),
      )
    },
  })

  return {
    query,
    emptyQueryError,
    countries: computed(() => (countryResource.hasValue() ? countryResource.value() : [])),
    errorMessage: computed(() => emptyQueryError() ?? countryResource.error()?.message),
    isLoading: computed(() => countryResource.isLoading()),
    handleSearch: (value: string) => {
      emptyQueryError.set(value ? undefined : EMPTY_QUERY_ERROR)
      query.set(value)
    },
  }
}
  • Es una función normal, no una clase ni un servicio. Cada vez que la llamas crea sus propias señales, así que cada pantalla tiene su estado.
  • Usa inject(), que solo funciona mientras Angular construye el componente. Por eso se llama al declarar el campo de la clase, no dentro de un método.
  • Devuelve solo lo que la pantalla necesita: la lista, el mensaje de error, si está cargando (isLoading) y el manejador de la búsqueda.
  • delay(300) es a propósito y es opcional: la API responde tan rápido que el indicador de carga ni se vería. Retrasa la respuesta 300 ms para que se note. En una app real se quita. Tiene un efecto secundario que cuenta el Módulo C16: también retrasa la lista que sale del caché.

Paso 2: usarla en el componente

export class ByCountryComponent {
  BY_COUNTRY = hyphenatedToTitleCase(BY_COUNTRY)
  countrySearch = createCountrySearch(PROPERTY_COUNTRY)
}
<country-search [searchCriteria]="BY_COUNTRY" (searchValue)="countrySearch.handleSearch($event)" />

<country-list
  [countries]="countrySearch.countries()"
  [errorMessage]="countrySearch.errorMessage()"
  [isLoading]="countrySearch.isLoading()"
/>

El componente queda sin lógica: solo dice por qué propiedad busca. La plantilla ya no tiene el ternario con hasValue(), porque eso vive en un computed() dentro de la función. Una plantilla con operaciones se lee peor y, si se repite en varias pantallas, es fácil que una quede distinta.

A partir de este módulo, by-region recibe la misma country-search (en el Módulo C9 todavía no la tenía) y llama a createCountrySearch(PROPERTY_REGION). Sus botones de región llegan en el Módulo C17.

Paso 3: rxResource en vez de resource

Las dos APIs hacen lo mismo hacia fuera (value(), error(), isLoading()). Cambia lo que recibe el segundo parámetro:

API Opción Devuelve Cuándo conviene
resource loader una Promise fetch, async/await, sin RxJS
rxResource stream un Observable ya tienes un Observable (HttpClient)
  • Con rxResource se acaba el firstValueFrom: pasas el Observable tal cual.
  • Cancela la petición anterior. Si el usuario busca otra cosa mientras la primera petición sigue en vuelo, rxResource se desuscribe y la petición HTTP se cancela. Con resource + firstValueFrom la petición vieja seguía viva y solo se ignoraba su resultado.
  • La opción se llama stream, no loader. Un tutorial de resource copiado aquí falla porque loader no existe.

Hay un detalle distinto a C12. Allí params devolvía undefined para dejar el recurso inactivo. En esta función params devuelve el texto tal cual, y cuando está vacío el stream devuelve of([]): una lista vacía, sin tocar la API. El resultado es el mismo para el usuario (no hay petición), pero el recurso queda con valor [] en lugar de inactivo.

Paso 4: tap y no map para validar

tap((countries) => {
  if (!countries || countries.length === 0) {
    throw new Error(`${NO_COUNTRIES_FOUND_ERROR}: ${query}`)
  }
})

La validación mira la lista pero no la cambia. tap es el operador para eso: ejecuta algo con el valor y lo deja pasar igual. map serviría si además transformara la lista (ordenar, filtrar). Lanzar un error dentro de tap termina el flujo igual que dentro de map, y llega a error() del recurso.

Angular se está volviendo más versátil

Que existan resource y rxResource no es duplicación: es una muestra de hacia dónde va Angular.

  • Durante años, todo lo asíncrono en Angular pasaba por RxJS. Hoy la base del estado son las señales (signals), y RxJS pasa a ser una herramienta que usas cuando te conviene, no una obligación.
  • resource es la pieza básica: representa un valor que llega más tarde y funciona con promesas, así que sirve para fetch, async/await o cualquier API del navegador, sin importar RxJS.
  • rxResource es el puente: si tu código ya habla en Observable (como HttpClient), lo conectas sin convertir nada.
  • Hay también httpResource, pensado para una petición GET sencilla a partir de una URL reactiva. Antes de usarlo, revisa en la documentación oficial si ya no es experimental en tu versión de Angular.

La idea para llevarse: Angular ya no te obliga a elegir un solo estilo (Observables o promesas). Eliges según lo que tengas en la mano, y las dos formas terminan en las mismas señales que lee la plantilla.

¿Estado en el servicio o en el componente?

Podría parecer buena idea guardar los resultados en una señal del servicio y leerlos con computed() en los componentes. No conviene. CountryService es providedIn: 'root': hay una sola copia para toda la aplicación.

  • Buscar «Madrid» en capital y pasar a país mostraría esos resultados en la pantalla equivocada.
  • Dos búsquedas seguidas se pisarían en la misma señal, y se pierde la cancelación por petición.
  • Carga y error también tendrían que ser globales.

Por eso el servicio no guarda estado: devuelve un Observable. El estado (consulta, carga, error, resultados) lo tiene cada pantalla, dentro de su propia llamada a createCountrySearch. Un caché es otra cosa: un Map privado que ninguna pantalla lee y que sólo evita repetir peticiones. Ese sí va en el servicio (Módulo C16).

¿Genérico o una función por pantalla?

Una función genérica ayuda cuando varias pantallas comparten la misma lógica y solo cambia un parámetro, como aquí. Una función propia es mejor cuando una pantalla tiene necesidades distintas (otro endpoint, paginación, filtros).

Una regla práctica: no generalices con dos casos; hazlo cuando sean tres y sean de verdad iguales. Si la función empieza a llenarse de if (searchBy === ...), ya no es genérica. Dos funciones parecidas cuestan menos que una abstracción equivocada.

Instructivo: añadir una pantalla de búsqueda

  1. Elegir la propiedad por la que se busca (una constante, como PROPERTY_REGION).
  2. En el componente, un campo: countrySearch = createCountrySearch(PROPERTY_X).
  3. En la plantilla, enlazar (searchValue) a countrySearch.handleSearch($event).
  4. Pasar a la lista countrySearch.countries(), countrySearch.errorMessage() y countrySearch.isLoading().
  5. Si la pantalla necesita algo que la función no cubre, no añadir opciones a la función: escribir un rxResource propio en ese componente.

Errores comunes

  • Usar loader con rxResource. La opción se llama stream.
  • Llamar a createCountrySearch dentro de un método. inject() falla fuera del momento de construcción; se llama en el campo de la clase.
  • Guardar los resultados en una señal del servicio. Es compartida por todas las pantallas y se pisan.
  • Mantener firstValueFrom en un rxResource. Funciona, pero pierde la cancelación de la petición anterior.
  • Generalizar demasiado pronto. Con dos pantallas, copiar es más barato que una abstracción que luego hay que deshacer.

Resumen

  • createCountrySearch(propiedad) junta consulta, recurso, error, carga y lista en una sola función reutilizable.
  • rxResource recibe el Observable del servicio con stream y cancela la petición anterior.
  • La lectura segura de value() vive en un computed(), no en la plantilla.
  • El servicio no guarda estado reactivo; cada pantalla tiene el suyo. Un caché interno es distinto (Módulo C16).
  • resource, rxResource y httpResource muestran que Angular admite varios estilos asíncronos y los lleva a las mismas señales.

Preguntas de entrevista

1. ¿Cuál es la diferencia entre resource y rxResource?

Respuesta:

resource recibe un loader que devuelve una promesa; rxResource recibe un stream que devuelve un Observable. Por fuera se usan igual (value(), error(), isLoading()). rxResource es más cómodo con HttpClient, porque no hay que convertir el Observable, y cancela la petición anterior cuando cambian los params.

2. ¿Por qué Angular tiene resource basado en promesas si usa Observables?

Respuesta:

Porque Angular está haciendo RxJS opcional. Las señales son la base del estado y resource representa un valor asíncrono sin depender de RxJS, así que funciona con fetch o async/await. Los Observables siguen donde ya estaban (HttpClient, router, formularios), y rxResource conecta ambos mundos. Es una muestra de que el framework se vuelve más versátil.

3. ¿Por qué no guardar los resultados en una señal del servicio?

Respuesta:

Porque un servicio providedIn: 'root' es un singleton. Una sola señal de resultados la compartirían todas las pantallas: lo buscado en una aparecería en otra, y dos búsquedas seguidas se pisarían. El servicio debe devolver datos sin guardar estado, y cada componente mantiene el suyo.

4. ¿Cuándo conviene una función de búsqueda genérica y cuándo una por pantalla?

Respuesta:

Genérica cuando varias pantallas comparten la lógica y solo cambia un parámetro, como la propiedad de búsqueda. Una por pantalla cuando una de ellas tiene necesidades propias (otro endpoint, paginación). Si la función genérica empieza a llenarse de condiciones por pantalla, deja de ser genérica y conviene separar. Dos funciones parecidas cuestan menos que una abstracción equivocada.

5. ¿Por qué tap y no map para comprobar que la lista no está vacía?

Respuesta:

Porque la comprobación no cambia la lista, solo la revisa. tap sirve para ejecutar algo con el valor y dejarlo pasar; map es para transformarlo. Si dentro de tap se lanza un error, el flujo termina con error igual que con map, y el recurso lo expone en error().

Módulo C14: Una pantalla de detalle con parámetros de ruta

La lista de países ya tiene un enlace en cada fila que lleva a /country/by/ES. Este módulo construye esa pantalla: recibe el código de la URL, pide un solo país a la API y lo muestra con componentes de daisyUI. Reutiliza todo lo visto antes: rxResource (Módulo C13), los estados de carga y error, y el servicio tipado (Módulo C11).

El problema que resuelve

El usuario toca un país en la lista y quiere ver sus datos completos. La pregunta es cómo llega ese país a la pantalla de detalle. Hay dos caminos:

A) Signal o servicio:   lista ──(guarda el país)──► detalle lee el signal
B) La URL:              lista ──► /country/by/ES ──► detalle pide el país con el código

Se elige B. Con A, si el usuario recarga la página (F5), abre un enlace compartido o entra directo a /country/by/ES, el signal está vacío y la pantalla no tiene qué mostrar. Además, un servicio providedIn: 'root' es una sola copia para toda la aplicación (se explica en C13). Con B, la URL ya contiene todo lo necesario, y recarga, enlaces y el botón «atrás» funcionan solos.

Paso 1: la ruta con parámetro

{
  path: 'by/:code',
  component: SelectedCountryComponent,
},

:code es un parámetro de ruta: lo que el usuario escribe en esa parte de la URL (ES, FR…) queda disponible con el nombre code. La lista ya genera el enlace con [routerLink]="['/country/by', country.code]".

Paso 2: recibir el parámetro como input()

// app.config.ts
provideRouter(routes, withComponentInputBinding()),
// selected-country.component.ts
code = input.required<string>()

Con withComponentInputBinding(), el router copia cada parámetro de la ruta al input() del componente que tenga el mismo nombre. Así no hace falta ActivatedRoute ni suscribirse a nada: code() es una señal que cambia cuando cambia la URL. El nombre del input y el del parámetro (:code) deben ser iguales.

Paso 3: pedir el país con rxResource

countryResource = rxResource({
  params: () => this.code(),
  stream: ({ params: code }) =>
    this.countryService.searchByRequest(code, PROPERTY_CODE).pipe(
      map(([country]) => {
        if (!country) {
          throw new Error(`${NO_COUNTRIES_FOUND_ERROR}: ${code}`)
        }
        return country
      }),
    ),
})
  • params: () => this.code() lee la señal. Si el usuario pasa de /by/ES a /by/FR, el recurso pide el país nuevo y cancela la petición anterior. Con ngOnInit esto no ocurre solo.
  • searchByRequest(code, PROPERTY_CODE) es el mismo método que usan las búsquedas; solo cambia la propiedad. El servicio no se toca.
  • map(([country]) => country): la API responde con una lista, pero el detalle necesita un solo país. Los corchetes sacan el primer elemento.

Un error típico: escribir params: () => ({ code: this.code }). Eso pasa la función de la señal, no su valor. Falta el paréntesis: this.code().

Paso 4: el código que no existe

Si el usuario abre /country/by/BX1, la API no responde con un error: responde bien, con una lista vacía. Para el recurso eso es un éxito sin datos, y la pantalla se quedaría en blanco. Por eso, igual que con el campo de búsqueda vacío, se convierte en un error de verdad:

if (!country) {
  throw new Error(`${NO_COUNTRIES_FOUND_ERROR}: ${code}`)
}

Lo que se lanza dentro del flujo llega a countryResource.error(), y la plantilla lo muestra. Es la misma idea de C12 y C13: un resultado vacío que el usuario debe ver como un problema se trata como error.

Paso 5: la plantilla con daisyUI

@if (countryResource.isLoading()) {
  <div class="alert alert-info mt-2"><app-loader /> Loading...</div>
}

@if (countryResource.error()) {
  <app-not-found
    title="We couldn't find that country"
    [message]="countryResource.error()?.message"
    backLink="/country/by-capital"
    backLabel="Back to search"
  />
}

@if (countryResource.hasValue()) {
  @let country = countryResource.value();

  <article class="card lg:card-side bg-base-100 shadow-xl mt-4">
    <figure class="bg-base-200 p-6 lg:w-1/3">
      <img [src]="country.flag" [alt]="`Flag of ${country.name}`" />
    </figure>
    <div class="card-body lg:w-2/3">
      <h1 class="card-title text-3xl">{{ country.name }}</h1>
      <dl class="stats stats-vertical sm:stats-horizontal shadow mt-4">
        <div class="stat">
          <dt class="stat-title">Capital</dt>
          <dd class="stat-value text-2xl">{{ country.capital }}</dd>
        </div>
        <!-- población, idiomas y gobierno siguen el mismo patrón -->
      </dl>
    </div>
  </article>
}
Clase de daisyUI Qué hace
card Tarjeta; con lg:card-side la imagen va al lado en pantalla ancha
badge Etiqueta pequeña, ideal para el código y la región
stats / stat Bloque de cifras con título y valor
alert Aviso de carga
hero Bloque centrado sobre un fondo suave; lo usa la pantalla de error
btn Botón o enlace con aspecto de botón
  • Tres estados, tres bloques. Carga, error y valor son estados distintos del recurso; cada uno tiene su @if.
  • hasValue() antes de value(), por la misma razón de C12: leer value() con el recurso en error lanza una excepción.
  • @let country = ... guarda el valor en un nombre corto para no repetir countryResource.value() en cada línea (se explica en la Parte 2).
  • <dl>, <dt> y <dd> son la forma semántica de mostrar pares «nombre: valor», y los lectores de pantalla la entienden mejor que <div>.

Paso 6: un componente not-found para el error

Mostrar el error como una línea de texto funciona, pero se ve poco cuidado. En shared/components/not-found/ hay un componente genérico que la pantalla de detalle usa en el bloque de error. Es genérico a propósito: la pantalla decide los textos y a dónde lleva el botón, y el componente solo sabe dibujarlos.

@Component({
  selector: 'app-not-found',
  templateUrl: './not-found.component.html',
  imports: [RouterLink],
})
export class NotFoundComponent {
  title = input('Not found')
  message = input<string | undefined>()
  backLink = input('/')
  backLabel = input('Back to home')
}
<section class="hero bg-base-200 rounded-box mt-4" role="alert">
  <div class="hero-content flex-col py-12 text-center">
    <svg class="text-primary" width="4em" height="4em" viewBox="0 0 24 24" aria-hidden="true">
      <!-- lupa con una cruz, en SVG en línea como el loader -->
    </svg>
    <h2 class="text-3xl font-bold">{{ title() }}</h2>
    @if (message()) {
      <p class="text-base-content/70 max-w-md">{{ message() }}</p>
    }
    <a class="btn btn-primary" [routerLink]="backLink()">{{ backLabel() }}</a>
  </div>
</section>
  • Los input() con valor por defecto (input('Not found')) son opcionales: si la pantalla no los pasa, se usa ese texto. Solo message puede faltar, y por eso la plantilla lo dibuja dentro de un @if.
  • Mismo estilo que el resto. El icono es un SVG en línea que toma el color del texto (currentColor), igual que el loader, y los colores salen del tema de daisyUI (text-primary, btn-primary, bg-base-200), así que cambian solos al cambiar de tema.
  • aria-hidden="true" en el icono y role="alert" en el bloque: el lector de pantalla anuncia el aviso sin leer el dibujo.
  • El mensaje técnico (No countries found…: BX1) se pasa en message, y el título y el botón son los que ve una persona.

Paso 7: volver a la pantalla anterior con Location

El botón de la tarjeta decía Back to search y llevaba siempre a /country/by-capital. Si venías de By Region, te mandaba a otra pantalla y perdías tu búsqueda. Lo que el usuario espera es lo mismo que hace el botón Atrás del navegador.

import { DecimalPipe, Location } from '@angular/common'

export class SelectedCountryComponent {
  private location = inject(Location)

  goBack() {
    this.location.back()
  }
}
<a class="btn btn-ghost" (click)="goBack()">Back to search</a>

Location es un servicio de Angular que envuelve el historial del navegador, y back() retrocede una entrada. Funciona bien junto al Módulo C18: la búsqueda vive en la URL (?query=europe) y se guarda con replaceUrl, así que la lista sólo ocupa una entrada del historial. Un clic en el botón vuelve a /country/by-region?query=europe, y la pantalla lee ese parámetro al crearse: la lista aparece como la dejaste.

Dos límites del botón actual:

  • Entrada directa. Si alguien abre /country/by/ES desde un enlace compartido, no hay pantalla anterior dentro de tu sitio. back() lo sacaría de la aplicación o no haría nada. La solución es un respaldo: Angular guarda un navigationId en history.state y el primero vale 1.
goBack() {
  if (history.state?.navigationId > 1) {
    this.location.back()
  } else {
    this.router.navigateByUrl('/country/by-capital')
  }
}
  • Un <a> sin href no se alcanza con el teclado. Con routerLink sí había enlace; con sólo (click), quien navega con Tab se lo salta. Para una acción (no un destino), lo correcto es <button class="btn btn-ghost">.

El <app-not-found> de arriba sigue con un enlace fijo a la búsqueda, y eso sí tiene sentido: ahí no hay nada a lo que volver.

Instructivo: añadir una pantalla de detalle

  1. Declarar la ruta con parámetro ('by/:code').
  2. Activar withComponentInputBinding() en provideRouter.
  3. En el componente, code = input.required<string>() con el mismo nombre que el parámetro.
  4. Crear un rxResource cuyo params lea this.code() y cuyo stream llame al servicio.
  5. Convertir la lista vacía en un Error.
  6. En la plantilla, un bloque por estado (carga, error, valor) y hasValue() antes de leer value(). Para el error, <app-not-found> con el texto y el enlace de esa pantalla.
  7. Un botón de volver con inject(Location) y location.back(), con un respaldo para quien entra por un enlace directo.

Errores comunes

  • Pasar el país por un signal o un servicio. Se pierde al recargar y al abrir un enlace directo.
  • Olvidar withComponentInputBinding(). El input() queda sin valor y Angular lanza un error porque es obligatorio.
  • Nombres distintos entre el parámetro de la ruta (:code) y el input().
  • params: () => this.code en vez de this.code().
  • Dejar que una lista vacía pase como éxito. La pantalla queda en blanco sin explicar nada.
  • Un «volver» con enlace fijo. Siempre lleva a la misma pantalla y pierde la búsqueda; Location.back() respeta de dónde vino el usuario.
  • back() sin respaldo. Con un enlace compartido no hay historial propio y el usuario sale del sitio.

Resumen

  • La URL es la fuente de verdad: lleva el código del país.
  • withComponentInputBinding() convierte los parámetros de ruta en input().
  • rxResource pide el país cada vez que cambia el código y cancela la petición anterior.
  • Una lista vacía de la API se convierte en un error para que el usuario lo vea.
  • daisyUI arma la pantalla con card, stats, badge, alert y btn.
  • El error se muestra con un componente not-found genérico, que recibe los textos y el enlace por input().
  • Para volver, Location.back() imita el botón Atrás; con la búsqueda en la URL (Módulo C18) devuelve la lista como estaba.

Preguntas de entrevista

1. ¿Cómo le pasas a una pantalla de detalle el elemento elegido en una lista?

Respuesta:

Con la URL: la lista navega a /detalle/:id y el detalle pide ese elemento con el id. Un signal o un servicio comparten el dato solo mientras la aplicación sigue abierta; al recargar, abrir un enlace directo o compartirlo, el dato ya no está. Con el id en la URL, esos tres casos funcionan.

2. ¿Qué hace withComponentInputBinding()?

Respuesta:

Hace que el router copie los parámetros de la ruta (y los de consulta, ?x=1) a los input() del componente que tengan el mismo nombre. Así el componente recibe code como una señal sin inyectar ActivatedRoute ni suscribirse.

3. ¿Qué pasa si el usuario cambia de /by/ES a /by/FR sin salir de la pantalla?

Respuesta:

Angular reutiliza el mismo componente y solo cambia el valor del input(). Como params del rxResource lee esa señal, el recurso pide el país nuevo y cancela la petición anterior. Con ngOnInit se leería el código una sola vez y la pantalla no se actualizaría.

4. La API devuelve 200 con una lista vacía para un código que no existe. ¿Cómo lo manejas?

Respuesta:

Lo convierto en un error dentro del flujo: si no hay primer elemento, lanzo un Error con un mensaje claro. El recurso lo expone en error() y la plantilla lo muestra. Si no, el recurso lo vería como un éxito sin datos y el usuario vería una pantalla en blanco.

5. ¿Por qué se lee value() solo dentro de hasValue()?

Respuesta:

Porque cuando el recurso está en error, value() lanza una excepción y Angular aborta toda la plantilla, incluido el mensaje de error. hasValue() indica que hay un valor que se puede leer sin riesgo.

Respuesta:

Depende de qué significa «volver». Si es un destino fijo («ir al inicio»), routerLink: es un enlace real, se puede abrir en otra pestaña y se alcanza con el teclado. Si es «regresar a donde estaba», Location.back(): retrocede en el historial y respeta la pantalla de origen y su búsqueda. Cuidado con la entrada directa por un enlace compartido: no hay historial propio, así que hace falta un respaldo (por ejemplo navigateByUrl a una pantalla conocida cuando history.state.navigationId es 1). Y como back() es una acción, va en un <button>, no en un <a> sin href.


Módulo C15: Buscar mientras se escribe, con effect y un retardo

El problema que resuelve

Hasta ahora la búsqueda se lanzaba con Enter o con el botón. Lo que esperas de un buscador moderno es que busque mientras escribes. Pero no sirve pedir a la API con cada tecla: al escribir «madrid» saldrían seis peticiones, cinco de ellas inútiles. Hace falta esperar a que dejes de escribir un momento y buscar una sola vez. A esa espera se le llama debounce («rebote»): la petición se retrasa, y si llega otra tecla antes de tiempo, se cancela la espera y vuelve a empezar.

En este proyecto se resuelve con herramientas de Angular, sin RxJS: una señal con el texto, un effect que reacciona a esa señal y un setTimeout que se cancela cuando llega una tecla nueva.

Paso 1: una señal con lo que hay en la caja

// components/search/search.component.ts
import { Component, effect, input, output, signal } from '@angular/core'
import { CLEAN_UP_TIMEOUT } from '../../constants/country.constants'

export class SearchComponent {
  searchCriteria = input.required<string>()
  debounceTime = input<number>(CLEAN_UP_TIMEOUT)
  inputValue = signal<string>('')
  searchValue = output<string>()

  handleSearch(value: string) {
    this.searchValue.emit(value.trim())
  }

  handleInput(value: string) {
    this.inputValue.set(value.trim())
  }
}
<!-- components/search/search.component.html -->
<input
  type="text"
  #capitalInput
  (keyup.enter)="handleSearch(capitalInput.value)"
  (keyup)="handleInput(capitalInput.value)"
/>

Dos cosas nuevas:

  • (keyup) se dispara con cada tecla y guarda el texto en la señal inputValue. Enter y el botón siguen llamando a handleSearch como antes (Módulo C9).
  • debounceTime es un input() con valor por defecto: la constante CLEAN_UP_TIMEOUT (500 ms). Cada pantalla puede cambiarlo; la de países pasa [debounceTime]="400".

Paso 2: un effect que espera y se cancela

debounceEffect = effect((onCleanup) => {
  const value = this.inputValue()
  if (!value) {
    return
  }
  const timeout = setTimeout(() => {
    this.searchValue.emit(value)
  }, this.debounceTime())

  onCleanup(() => clearTimeout(timeout))
})

Un effect es una función que Angular vuelve a ejecutar cada vez que cambia una señal que leyó dentro. Aquí lee inputValue() y debounceTime(), así que corre con cada tecla. Lo que hace en cada vuelta:

  1. Lee el texto. Si está vacío, no programa nada y sale.
  2. Programa un setTimeout: dentro de 500 ms, emite el texto al padre.
  3. Registra con onCleanup qué hacer antes de la próxima vuelta o cuando el componente se destruya: cancelar ese setTimeout.

El paso 3 es el truco. Si escribes «m», se programa una búsqueda de «m». Si antes de 500 ms escribes «a», el efecto vuelve a correr: primero ejecuta la limpieza (cancela la búsqueda de «m») y luego programa la de «ma». Al final sólo se emite el texto que quedó quieto medio segundo.

tecla:    m      a      d      r      i      d        (quieto)
          |      |      |      |      |      |            |
efecto:  [m]  x [ma] x [mad] x [madr] x [madri] x [madrid] --500 ms--> emit('madrid')

Cada x es un clearTimeout hecho por onCleanup. Sólo el último temporizador llega a cumplirse.

Paso 3: Enter y el retardo conviven

Si escribes «madrid» y pulsas Enter antes de los 500 ms, pasan dos cosas: Enter emite «madrid» ya mismo, y el setTimeout pendiente emite «madrid» otra vez cuando se cumple. ¿Dos peticiones? No. El padre guarda el texto en la señal query de createCountrySearch (Módulo C13), y una señal que recibe el mismo valor no avisa a nadie: rxResource no ve cambio y no pide nada. Y desde el Módulo C16, aunque pidiera, el servicio ya la tendría guardada.

Otro detalle: borrar todo el texto no busca nada (el efecto sale al ver el texto vacío), así que la lista anterior se queda en pantalla. Es una decisión; si prefieres vaciar la lista, emite el texto vacío y deja que handleSearch del padre muestre su mensaje de consulta vacía.

Por qué un effect y no debounceTime de RxJS

La forma clásica en Angular era RxJS: fromEvent(input, 'keyup'), debounceTime(500), distinctUntilChanged() y una suscripción que hay que cerrar. Funciona igual de bien. La versión con señales tiene dos ventajas para un componente pequeño: no hay suscripción que olvidar cerrar (Angular destruye el efecto con el componente, y onCleanup cancela el temporizador) y el retardo es un input() normal, configurable desde la plantilla. Si la lógica creciera (cancelar peticiones, combinar varias fuentes), RxJS vuelve a ser la herramienta adecuada.

Instructivo: añadir un retardo a una caja de búsqueda

  1. Una señal con el texto, actualizada desde (keyup).
  2. Un effect que lee la señal, programa un setTimeout y lo cancela en onCleanup.
  3. El tiempo como input() con valor por defecto en una constante.
  4. Comprobar en la pestaña Network del navegador que al escribir una palabra entera sale una petición, no una por letra.

Errores comunes

  • Olvidar onCleanup. Cada tecla programa una búsqueda y ninguna se cancela: salen tantas peticiones como letras, sólo que retrasadas.
  • Declarar el effect dentro de un método. effect() necesita un contexto de inyección: va en un campo de la clase o en el constructor.
  • Leer la señal después del setTimeout. Si el efecto no lee inputValue() en su cuerpo, Angular no sabe que depende de ella y no lo vuelve a ejecutar.
  • Un retardo demasiado largo. Un segundo ya se siente lento. Entre 300 y 500 ms es lo habitual.

Resumen

  • (keyup) guarda el texto en una señal; Enter y el botón siguen igual.
  • Un effect corre con cada cambio de la señal, programa un setTimeout y lo cancela en onCleanup si llega otra tecla.
  • Sólo se emite el texto que se quedó quieto el tiempo configurado.
  • Emitir el mismo valor dos veces no repite la petición: la señal query no cambia.
  • El retardo es un input() con valor por defecto en una constante.

Preguntas de entrevista

1. ¿Qué es debounce y por qué hace falta en un buscador?

Respuesta:

Es esperar a que el usuario deje de escribir un momento antes de actuar. Sin él, cada tecla lanza una petición: la mayoría se descartan y aun así cargan al servidor y pueden llegar desordenadas. Con un retardo de 300 a 500 ms, una palabra entera cuesta una sola petición.

2. ¿Para qué sirve onCleanup en un effect?

Respuesta:

Para deshacer lo que el efecto dejó pendiente antes de que vuelva a correr o cuando el componente se destruye: cancelar un temporizador, cerrar una suscripción, abortar una petición. Aquí cancela el setTimeout de la tecla anterior; sin eso, el retardo no cancelaría nada y saldría una petición por letra.

3. Si Enter y el temporizador emiten el mismo texto, ¿por qué no se hacen dos peticiones?

Respuesta:

Porque el texto llega a una señal (query) y las señales comparan el valor nuevo con el anterior: si es igual, no notifican. rxResource depende de esa señal, no ve ningún cambio y no vuelve a pedir. Y si el valor sí cambiara, el caché del servicio evitaría la segunda petición de todos modos.

4. ¿Cuándo preferirías debounceTime de RxJS a un effect con setTimeout?

Respuesta:

Cuando la lógica del flujo crece: combinar varias fuentes, cancelar la petición en curso con switchMap, descartar repetidos con distinctUntilChanged. RxJS expresa eso en una cadena corta. Para «espera medio segundo y emite», el efecto es más simple y no deja suscripciones abiertas.


Módulo C16: Un caché en el servicio con Map

El problema que resuelve

Buscas «madrid», vas a otra pantalla, vuelves y buscas «madrid» otra vez. La respuesta es la misma, pero la aplicación la pide de nuevo. Con el buscador en tiempo real del Módulo C15 pasa más aún: escribir, borrar y volver a escribir repite consultas idénticas. Un caché (cache) guarda cada respuesta junto a la consulta que la produjo; la segunda vez, la devuelve sin ir a la API.

Paso 1: un Map por tipo de búsqueda

// services/country.service.ts
export class CountryService {
  private http = inject(HttpClient)
  private apiUrl = environment.restCountriesUrl
  private queryCapitalCache: Map<string, Country[]> = new Map<string, Country[]>()
  private queryCountryCache: Map<string, Country[]> = new Map<string, Country[]>()

Un Map es un diccionario: a cada clave le corresponde un valor. Aquí la clave es el texto buscado y el valor, la lista de países que devolvió la API. Hay uno para la búsqueda por capital y otro para searchByRequest (nombre, región y código). La comparación con Set y con un objeto normal está en la Parte 1 (Tipos de datos).

Paso 2: mirar antes de pedir, guardar después de recibir

searchByCapital(query: string): Observable<Country[]> {
  query = query.trim().toLocaleLowerCase()
  const url = `${this.apiUrl}?${PROPERTY_CAPITAL}=${query}`

  if (this.queryCapitalCache.has(query)) {
    return of(this.queryCapitalCache.get(query) ?? [])
  }

  console.log('Caching query result for:', query)
  return this.http.get<RESTCountryResponse>(url).pipe(
    map((response) => CountryMapper.mapToCountry(response.data.objects)),
    tap((countries) => this.queryCapitalCache.set(query, countries)),
    catchError((err: HttpErrorResponse) => {
      const message = err.error?.errors?.[0]?.message ?? err.message
      return throwError(() => new Error(message))
    }),
  )
}

searchByRequest hace lo mismo con queryCountryCache, con una diferencia: ese método sirve para nombre, región y código, así que la clave lleva también la propiedad (${searchBy}:${query}, por ejemplo region:europe). Si la clave fuera sólo el texto, buscar la región «europe» y luego «europe» por nombre devolvería la misma lista en vez de «no hay países». El flujo:

searchByCapital('Madrid')
   |
   +-- normalizar: 'madrid'
   |
   +-- ¿está en el Map? -- sí --> of(lista guardada)      (sin petición)
   |
   +-- no --> http.get(url)
               +-- map: respuesta -> Country[]
               +-- tap: Map.set('madrid', Country[])      (guardar)
               +-- catchError: Error con mensaje          (no se guarda)

Tres decisiones que conviene entender:

  • Normalizar antes de buscar en el Map. trim() y minúsculas hacen que «Madrid», «madrid » y «MADRID» sean la misma clave. Sin eso, el caché guardaría tres copias.
  • of(...) para que el contrato no cambie. El método promete un Observable<Country[]>. Quien lo llama (rxResource, subscribe) no sabe ni necesita saber si la lista vino de la red o del Map. of envuelve el valor en un Observable que lo emite y se completa.
  • tap guarda, catchError no. tap corre sólo cuando la petición tuvo éxito, así que los errores (404, red caída) no se guardan y la próxima vez se vuelven a intentar. Es lo que quieres: un error de hoy no tiene por qué repetirse mañana.

Paso 3: comprobar que funciona

Abre la pestaña Network del navegador y busca «madrid» dos veces. La primera muestra la petición a /api/countries y la consola imprime Caching query result for: madrid (es una traza de desarrollo; quítala cuando ya no la necesites). La segunda no muestra petición ni traza: la lista salió del Map.

Un matiz: en las pantallas que usan createCountrySearch, la lista cacheada también tarda 300 ms en aparecer, porque el delay(300) del Módulo C13 está después del servicio. Si molesta, se mueve dentro del servicio, sólo en el camino de la red.

Dónde vive el caché y cuánto dura

El Map es un campo de un servicio providedIn: 'root': una sola copia para toda la aplicación, compartida por todas las pantallas. Vive en memoria mientras la pestaña esté abierta; al recargar, empieza vacío. No va a localStorage: un Map no se serializa con JSON.stringify y, sobre todo, los datos son de la API, no del usuario. Lo que sí tiene sentido guardar entre recargas es la última consulta, para repetirla al volver.

Esto no contradice el Módulo C13 («el servicio no guarda estado»). Lo que el servicio no debe tener es estado reactivo que las pantallas lean (una señal con «los resultados»): se pisarían entre sí. El caché es un detalle interno: ninguna pantalla lo lee, sólo acelera la respuesta.

Instructivo: añadir un caché a un método de servicio

  1. Un Map<string, T> privado por cada tipo de consulta.
  2. Normalizar la clave (trim, minúsculas) y, si el método busca por varias propiedades, incluir la propiedad en la clave.
  3. Antes de pedir: si cache.has(key), devolver of(cache.get(key) ?? []).
  4. En el pipe, después del map: tap((value) => cache.set(key, value)).
  5. Verificar en Network que la segunda consulta no hace petición.

Errores comunes

  • Una clave que no distingue el tipo de búsqueda. Si un método sirve para varias propiedades y la clave es sólo el texto, dos búsquedas distintas con el mismo texto comparten entrada. La clave tiene que incluir la propiedad.
  • Guardar también los errores. Si el set fuera antes del catchError o dentro de él, un fallo de red quedaría cacheado para siempre.
  • Olvidar normalizar. «Madrid» y «madrid» como dos entradas distintas.
  • Guardar el caché en una señal y mutarlo. cache().set(...) no cambia la referencia y nadie se entera. Aquí no hace falta señal: es un Map privado.
  • No tener forma de vaciarlo. Para datos que cambian, hace falta una fecha de guardado y un límite de edad, o un clear() al salir de sesión. Los países cambian poco, por eso aquí no hay caducidad.

Resumen

  • Un Map<string, Country[]> privado por tipo de búsqueda, en el servicio.
  • Clave normalizada; has y get antes de pedir; tap guarda después de recibir; los errores no se guardan.
  • of(lista) devuelve lo cacheado con el mismo contrato Observable.
  • Vive en memoria, una copia para toda la aplicación; se pierde al recargar.
  • Es un detalle interno del servicio, no estado reactivo compartido.

Preguntas de entrevista

1. ¿Por qué un Map y no un objeto {} para el caché?

Respuesta:

Las dos cosas funcionan para claves de texto. El Map tiene has, get, set, delete, clear y size sin trucos, acepta claves de cualquier tipo, mantiene el orden de inserción y no hereda propiedades de Object.prototype (con un objeto, cache['constructor'] existe aunque nunca lo guardaras). Y get devuelve T | undefined, que obliga a tratar el caso de clave ausente.

2. ¿Dónde pones el caché: en el componente, en el servicio o en un interceptor?

Respuesta:

En el servicio cuando el caché es de datos de dominio y varios componentes se benefician: una sola copia, cerca de quien hace la petición. En el componente sólo si el dato es de esa pantalla y nadie más lo usa. En un interceptor HTTP cuando quieres cachear cualquier GET por URL sin tocar los servicios; es más genérico, pero más opaco y difícil de invalidar por concepto.

3. ¿Qué pasa si la API responde con error? ¿Se guarda?

Respuesta:

No. tap sólo corre con una respuesta correcta; si la petición falla, el flujo salta a catchError sin pasar por tap, así que el Map no se toca y la siguiente consulta vuelve a intentar. Cachear errores haría que un fallo pasajero de red pareciera permanente.

4. ¿Cómo invalidarías este caché?

Respuesta:

Depende de por qué caduca. Por tiempo: guardar junto al valor la hora en que se obtuvo y, al leer, descartar si pasó un límite. Por acción: clear() al cerrar sesión o cuando el usuario edita algo que afecta a esos datos. Por memoria: limitar el número de entradas y borrar la más antigua, que con un Map es la primera clave, porque mantiene el orden de inserción.

5. Este caché devuelve la lista con of(...). ¿Qué cambia para quien llama?

Respuesta:

Nada, y esa es la gracia. El método sigue devolviendo un Observable<Country[]>; of emite el valor guardado y se completa. rxResource o subscribe reciben lo mismo que recibirían de HttpClient. La única diferencia observable es que llega de forma síncrona, sin pasar por la red.

Módulo C17: Botones de región con @for y un tipo de unión

El problema que resuelve

En la pantalla By Region el usuario tiene que escribir «europe» sin errores. Pero las regiones que acepta la API son pocas y siempre las mismas. Para una lista cerrada, un botón por opción es mejor que un campo de texto: no hay erratas y se ve de un vistazo qué se puede buscar.

Lo bueno es que casi no hay que escribir código nuevo: la búsqueda genérica del Módulo C13 ya sabe buscar por región. Sólo falta una lista de regiones y un botón por cada una.

Paso 1: un tipo con las regiones válidas

// interfaces/country.interfaces.ts
export type Region = 'Africa' | 'Americas' | 'Asia' | 'Europe' | 'Oceania' | 'Antarctic'

Es un tipo de unión (union type): una variable de tipo Region sólo puede valer una de esas seis palabras. Si escribes 'Europa', TypeScript avisa antes de que ejecutes nada. Es la misma idea de los tipos literales de la Parte 1.

Paso 2: la lista, en las constantes

// constants/country.constants.ts
export const regions: Region[] = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania', 'Antarctic']

Va junto a las demás constantes de la funcionalidad, así el componente no guarda datos y la lista se puede reutilizar. Al tipar el arreglo como Region[], un elemento mal escrito rompe la compilación.

Paso 3: el componente sólo la expone

// pages/by-region/by-region.component.ts
export class ByRegionComponent {
  BY_REGION = hyphenatedToTitleCase(BY_REGION)
  regionSearch = createCountrySearch(PROPERTY_REGION)
  regions = regions
}

regions = regions parece raro, pero es necesario: la plantilla sólo ve las propiedades de la clase, no los import del archivo. Asignarla a un campo la vuelve visible para el HTML.

Fíjate en lo que no cambió: createCountrySearch(PROPERTY_REGION) es la misma llamada de antes. Esa es la ganancia de haber hecho la búsqueda genérica en el Módulo C13: una pantalla nueva con otra interfaz no toca la lógica.

Paso 4: un botón por región, con @for

<!-- pages/by-region/by-region.component.html -->
<section class="my-5 row">
  @for (region of regions; track $index) {
    <button
      (click)="regionSearch.handleSearch(region)"
      class="btn btn-outline btn-danger mr-5"
      [class.btn-accent]="regionSearch.query() === region"
    >
      {{ region }}
    </button>
  }
</section>

Tres piezas:

  • @for repite el botón una vez por elemento de regions. track le dice a Angular cómo reconocer cada elemento para no recrear todo el HTML si la lista cambia.
  • (click)="regionSearch.handleSearch(region)" llama al mismo método que usa el cuadro de texto. Cambia la señal query, y el rxResource del Módulo C13 vuelve a pedir los países. Tecleando o pulsando, el camino es el mismo.
  • [class.btn-accent]="regionSearch.query() === region" añade la clase sólo al botón cuya región coincide con la búsqueda actual. Así el botón activo se pinta distinto sin guardar ninguna señal extra: ya existe query.
click en «Europe»
   |
   +-- handleSearch('Europe') --> query.set('Europe')
                                     |
                       +-------------+--------------+
                       |                            |
          rxResource pide los países       [class.btn-accent] se recalcula:
          (searchByRequest, region)         «Europe» se pinta como activo

Paso 5: un detalle de estilos

En el mismo cambio se corrigió la lista de errores:

<div class="alert alert-error mt-2">{{ errorMessage() }}</div>

Antes decía alert-danger. Esa clase es de otra librería (Bootstrap); en daisyUI el color de peligro se llama error. Con un nombre que no existe, el navegador no se queja: simplemente no aplica ningún estilo.

Instructivo: convertir una lista cerrada en botones

  1. Un tipo de unión con las opciones válidas.
  2. Un arreglo constante con ese tipo, en el archivo de constantes.
  3. En el componente, un campo que apunte al arreglo (regions = regions).
  4. En la plantilla, un @for con un <button> que llame a la búsqueda.
  5. [class.clase]="actual() === opción" para marcar el botón activo.
  6. Comprobar en el navegador: pulsar una región muestra países y marca el botón.

Errores comunes

  • Usar clases de otra librería. En el repositorio quedan btn-danger y row: ninguna existe en daisyUI ni en Tailwind (los colores son btn-error, btn-primary, etc., y la fila se hace con flex). El botón se ve bien por btn y btn-outline, pero esas dos clases no hacen nada. Queda pendiente.
  • track $index en una lista que puede cambiar. Aquí la lista es fija y no molesta; si cambiara de orden, track region (un valor único) es más seguro.
  • Olvidar regions = regions. La plantilla no encuentra la propiedad regions aunque el import esté bien.
  • Llevar la cuenta del botón activo en otra señal. Ya existe query; una segunda señal puede quedar desincronizada.
  • La clave del caché. Pulsar «Europe» guarda sus países en el caché del Módulo C16 bajo europe; si después buscas «europe» por nombre, se muestra esa misma lista. Es el mismo defecto que ya se describió allí: la clave debería incluir la propiedad. Con los botones es mucho más fácil caer en él.

Resumen

  • Una lista cerrada de opciones se convierte en botones con @for.
  • Un tipo de unión (Region) más un arreglo tipado evitan las erratas.
  • Los botones llaman al mismo handleSearch que el cuadro de texto: la lógica de búsqueda no cambia.
  • [class.x]="query() === valor" marca el botón activo sin estado extra.
  • Un nombre de clase que no existe no da error: revisa que sea de tu librería.

Preguntas de entrevista

1. ¿Por qué un tipo de unión en vez de string[] para las regiones?

Respuesta:

Porque string acepta cualquier texto y el compilador no puede avisar de una errata. Con 'Africa' | 'Americas' | ..., sólo las seis palabras válidas compilan. Además, si más adelante una función recibe un Region, el editor autocompleta las opciones.

2. ¿Por qué la plantilla necesita regions = regions si ya importaste la constante?

Respuesta:

Las plantillas de Angular sólo ven los miembros de la clase del componente, no las variables del módulo. Un import está en el archivo, no en la clase. Hay que asignarlo a un campo (o exponerlo con un computed) para que el HTML lo alcance.

3. ¿Para qué sirve track en @for y por qué es obligatorio?

Respuesta:

Le dice a Angular cómo identificar cada elemento entre una actualización y otra. Si la lista cambia, Angular reutiliza los elementos del DOM que siguen existiendo y sólo crea o borra los que cambiaron. Es obligatorio para que nadie olvide pensarlo: un track malo (por ejemplo el objeto entero cuando se crean copias nuevas) obliga a recrear todo el HTML en cada cambio.

4. ¿Cómo marcas el botón activo sin guardar una señal nueva?

Respuesta:

Comparando contra el estado que ya existe. [class.btn-accent]="regionSearch.query() === region" se recalcula cada vez que cambia query, porque lee la señal. Una segunda señal («región seleccionada») duplicaría lo que query ya sabe y podría quedar desincronizada.

5. ¿Qué ventaja tuvo haber hecho la búsqueda genérica antes?

Respuesta:

Que esta pantalla casi no necesita código nuevo: sólo datos (la lista de regiones) y una plantilla. createCountrySearch ya manejaba carga, error, caché y consulta vacía. Cuando una función reutilizable se usa por tercera vez sin cambios, la abstracción ya se pagó sola.

Módulo C18: Guardar la búsqueda en la URL con ActivatedRoute y Router

El problema que resuelve

Buscas «europe» en By Region, ves los países y copias la dirección del navegador para mandársela a alguien. Cuando la abre, ve la pantalla vacía: la búsqueda vivía sólo en una señal dentro de tu pestaña. Lo mismo pasa si recargas la página.

La solución es que la búsqueda también viva en la dirección: /country/by-region?query=europe. Esa parte final (?query=europe) se llama parámetro de consulta (query parameter). Con ella en la URL, recargar, compartir o guardar en favoritos devuelve la misma pantalla.

Hacen falta dos mitades:

  • Leer la URL al entrar (ActivatedRoute).
  • Escribir la URL cada vez que se busca (Router).

Paso 1: leer el parámetro al empezar

// utils/country-search-resource.ts
export function createCountrySearch(searchBy: string) {
  const countryService = inject(CountryService)
  const activatedRoute = inject(ActivatedRoute)
  const router = inject(Router)
  const query = signal(activatedRoute.snapshot.queryParamMap.get('query') ?? '')
  • ActivatedRoute es «la ruta que está activa ahora»: sabe su URL, sus parámetros y sus datos.
  • snapshot es una foto de la ruta en el momento de entrar. No se actualiza sola, pero para el valor inicial basta.
  • queryParamMap.get('query') devuelve el texto o null si el parámetro no existe; ?? '' convierte ese null en un texto vacío.

El rxResource del Módulo C13 ya lee query(). Como ahora query arranca con el valor de la URL en vez de vacío, la primera búsqueda se dispara sola al cargar la pantalla. No hizo falta tocar nada más.

inject() funciona dentro de createCountrySearch porque la función se llama desde un campo del componente (regionSearch = createCountrySearch(...)), y ahí Angular permite inyectar. Es el mismo motivo por el que ya inyectaba CountryService.

Paso 2: escribir el parámetro al buscar

handleSearch: (value: string) => {
  emptyQueryError.set(value ? undefined : EMPTY_QUERY_ERROR)
  query.set(value)
  router.navigate([], {
    relativeTo: activatedRoute,
    queryParams: { query: value || null },
    replaceUrl: true,
  })
},

router.navigate cambia la dirección sin recargar la página. Sus tres piezas:

Opción Qué hace
[] con relativeTo: activatedRoute «Quédate en esta misma ruta»; sólo cambia lo demás
queryParams: { query: ... } Pone ?query=valor; si el valor es null, borra el parámetro
replaceUrl: true Reemplaza la entrada del historial en vez de agregar otra

El replaceUrl importa por el buscador en tiempo real del Módulo C15: se busca cada vez que dejas de teclear. Sin él, cada búsqueda sería una entrada nueva en el historial y el botón Atrás te haría recorrer «m», «ma», «mad»…

escribir «madrid»  --> handleSearch('madrid')
                          |
                          +-- query.set('madrid')   --> rxResource pide los países
                          +-- router.navigate(...)  --> la URL pasa a ?query=madrid

recargar con ?query=madrid --> snapshot --> query = 'madrid' --> rxResource pide

Dónde se aplica

La pantalla de detalle (Módulo C14) también se beneficia: como la búsqueda sólo ocupa una entrada del historial, su botón de volver con Location.back() regresa a la lista con ?query= intacto (Paso 7 del Módulo C14).

Como handleSearch está dentro de la función genérica, by-country y by-region lo reciben sin cambiar una línea, y los botones de región del Módulo C17 también actualizan la URL porque llaman al mismo método.

by-capital es la excepción a propósito: todavía tiene su propio resource y su propio handleSearch, así que repite las mismas líneas a mano. Sirve para ver el costo de no haber generalizado: cualquier mejora hay que escribirla dos veces.

Instructivo: sincronizar una búsqueda con la URL

  1. Inyectar ActivatedRoute y Router.
  2. Iniciar la señal con snapshot.queryParamMap.get('nombre') ?? ''.
  3. En el método que busca, después de query.set(...), llamar a router.navigate([], { relativeTo, queryParams: { nombre: valor || null }, replaceUrl: true }).
  4. Probar: buscar, recargar (debe seguir ahí) y copiar la URL en otra pestaña.
  5. Vaciar la búsqueda y comprobar que el ?query= desaparece de la dirección.

Errores comunes

  • Olvidar el ?? ''. get() devuelve null si no hay parámetro, y la señal quedaría con un tipo mezclado.
  • queryParams sin null para el valor vacío. Quedaría ?query= en la URL. Con null se limpia.
  • Esperar que snapshot se actualice. Es una foto: si la URL cambia con esta pantalla ya abierta (por ejemplo con Atrás), query no se entera. Con replaceUrl casi no pasa, pero es el límite de este diseño.
  • Dos fuentes de verdad. La señal y la URL guardan lo mismo; si una cambia sin la otra, se desincronizan. Aquí handleSearch cambia las dos juntas.
  • El cuadro de texto queda vacío al entrar con ?query=madrid. La lista muestra resultados, pero SearchComponent no recibe un valor inicial y el campo aparece en blanco. Queda pendiente en el repositorio.

Resumen

  • Un parámetro de consulta (?query=...) hace que la búsqueda se pueda recargar, compartir y guardar en favoritos.
  • ActivatedRoute.snapshot lee el valor al entrar; Router.navigate lo escribe al buscar.
  • replaceUrl: true evita llenar el historial; null borra el parámetro.
  • Hecho en la función genérica, lo heredan todas las pantallas que la usan.
  • Con snapshot la URL no vuelve a la señal; para eso hace falta una fuente reactiva.

Preguntas de entrevista

1. ¿Por qué guardar el estado de una búsqueda en la URL y no sólo en una señal?

Respuesta:

Porque una señal vive en la memoria de esa pestaña: al recargar o al compartir el enlace se pierde. La URL sí sobrevive: se puede copiar, guardar en favoritos y reabrir. Además hace que la pantalla sea predecible: la misma dirección muestra siempre lo mismo.

2. ¿Qué diferencia hay entre snapshot y un observable como queryParamMap?

Respuesta:

snapshot es una foto del momento de entrar: se lee una vez y no cambia. El observable queryParamMap emite cada vez que cambian los parámetros mientras el componente sigue vivo. Con snapshot basta si sólo importa el valor inicial; si necesitas reaccionar al botón Atrás o a enlaces que cambian el parámetro sin recrear el componente, necesitas el observable (por ejemplo con toSignal).

3. ¿Para qué sirve replaceUrl: true?

Respuesta:

Para que cambiar la URL no agregue una entrada nueva al historial del navegador. En una búsqueda que se actualiza mientras escribes, sin esa opción Atrás recorrería cada búsqueda intermedia en vez de volver a la pantalla anterior.

4. ¿Cómo harías que Atrás y Adelante también cambien la búsqueda?

Respuesta:

Haría que la URL fuera la única fuente de verdad: en vez de una señal local, query = toSignal(route.queryParamMap.pipe(map(p => p.get('query') ?? '')), { initialValue: '' }), y que handleSearch sólo navegue. Así no hay dos copias que sincronizar. Si prefieres conservar una señal editable que además siga a la URL, linkedSignal sirve; con un simple snapshot no, porque no hay nada reactivo a lo que enlazarse. Y habría que quitar replaceUrl si quieres que cada búsqueda sea una entrada del historial.

5. ¿Por qué by-country y by-region no tuvieron que cambiar?

Respuesta:

Porque la lectura y la escritura de la URL están dentro de createCountrySearch, que ambas usan. Cambiar la función cambia todas las pantallas que la llaman. Es el beneficio de la abstracción, y el costo de by-capital, que no la usa, se ve claro: hubo que repetir el código a mano.