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()youtput()(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 conrxResourcey mostrarlo con daisyUI (Módulo C14). - Menos peticiones. Buscar mientras se escribe, con un
effectque espera a que dejes de teclear (Módulo C15), y un caché conMapen 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
ActivatedRouteyRouter, 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¶
- Arranca el servidor con
npm start(o compila connpm 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ó. - Escribe un componente de daisyUI en una plantilla:
<button class="btn btn-primary">Hola</button>
- 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 destyles.scss: Sass lo interpreta y avisa. Va en un.cssaparte. - Poner
tailwind.cssdespués destyles.scssenangular.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.jsonoangular.jsoncon el servidor encendido: hay que reiniciarnpm startpara 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
--defaulty--prefersdark: si está, el modo oscuro del sistema deja de importar. - Funciona en cualquier elemento, no sólo en
<html>. Una sección condata-theme="dark"se ve oscura dentro de una página clara. - Sin atributo, se aplican las banderas:
--default, o--prefersdarksi 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:
themees una señal con el tema actual. Arranca con el que haya guardado enlocalStorage, o consunsetsi no hay ninguno.- El
effectse ejecuta cada vez quethemecambia: escribe el atributo en<html>y guarda la elección para la próxima visita. inject(DOCUMENT)es la forma de Angular de llegar al documento, en lugar de usardocumentdirectamente.
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 haydata-themey la página puede quedar con colores inesperados. - Un nombre mal escrito en
data-themeo en la lista (synthwave, nosynth-wave): no falla, simplemente no aplica. - Fijar
data-themey 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.csscon el servidor encendido: hay que reiniciarnpm startpara 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 decountry: 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: Routesel editor autocompleta y avisa de errores, por eso se muestra tipado. - El archivo termina con
export default: es lo que permite escribirimport('./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/otraya no cae en el inicio sino enby-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í¶
- Crea una carpeta por funcionalidad (
country/,users/...), con sus páginas dentro (pages/). - En cada una, un archivo
<feature>.routes.tscon un arreglo tipado comoRoutes, yexport defaultde ese arreglo. - En
app.routes.ts, una entrada por funcionalidad:{ path: 'country', loadChildren: () => import('./country/country.routes') }. - Las pantallas sueltas que no pertenecen a una funcionalidad (inicio) van en
shared/pages/conloadComponent. - El comodín
**conredirectTova al final deapp.routes.ts. - En la plantilla raíz, un
<router-outlet />. - Para añadir una pantalla nueva a la funcionalidad, agregas una entrada en
su
*.routes.ts(por ejemplo{ path: 'by-region', component: ... }) y no tocasapp.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. Lospathvan sin barra al principio. - Repetir el prefijo en la ruta hija: si el padre ya es
country, la hija espath: 'by-capital', nopath: 'country/by-capital'. - Olvidar el
export defaulten 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 (pthen vez depathpasaría desapercibido). - Olvidar
<router-outlet />: la ruta cambia en la barra de direcciones, pero no se dibuja nada. - Usar
export defaulten componentes que no se cargan conimport(): 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á enapp.htmldibuja el layout; el del layout dibuja la pantalla. - Hay que importar
RouterOutleten el layout, y también el componente del menú, porque las plantillas sólo conocen lo que está enimports.
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¶
- Crea el componente layout en
<feature>/layouts/<nombre>-layout/. - En su plantilla, pon lo común (menú, cabecera, barra lateral) y un
<router-outlet />para el contenido. - Importa
RouterOutlety los componentes que use en elimportsdel layout. - En
<feature>.routes.ts, una ruta conpath: '',component: <Layout>ychildren: [...]con las pantallas. - Agrega un comodín
**como última hija, conredirectTohacia la pantalla por defecto. - 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 desdecountry. 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¶
- Decide el alcance: si lo usará toda la app va en
shared/components/<nombre>/; si es de una funcionalidad, en<feature>/components/<nombre>/. - Crea el componente con un selector con el prefijo que corresponda
(
app-…,<feature>-…). - Exporta la clase con nombre (
export class): se importará en otro componente, no por una ruta. - Impórtalo en el
importsdel componente que lo va a usar. - 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
sharedalgo que sólo usa una funcionalidad:sharedse vuelve un cajón de todo y se pierde la ventaja de separar. - Usar
export defaulten estos componentes: eldefaultes sólo para lo que se carga por una ruta conimport(). - 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:
viewBoxes 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).widthyheightson 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¶
- Copia el SVG con su
viewBoxoriginal y déjalo así: es la cuadrícula del dibujo (iconGrid). - En la clase, una propiedad con el tamaño en pantalla (
iconSize = 18), distinta de la cuadrícula. - En el
<svg>,[attr.width]="iconSize"y[attr.height]="iconSize". - En los trazos,
stroke="currentColor"(ofill="currentColor") para heredar el color. - Para algo que cambia según datos:
[attr.fill],[attr.d],[class.…]o[style.…]. - Si se repite mucho, un componente
iconconinput()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
viewBoxdel 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. ElviewBoxdepende del dibujo; el tamaño eswidthyheight. - Poner un color fijo en el trazo: el icono no sigue al tema. Usa
currentColor. - Declarar
viewBoxantes queiconGrid:viewBoxse calcula coniconGrid, y los campos se inicializan en orden; siiconGridva después, todavía no tiene valor. - Olvidar
xmlns: al pegar un SVG suelto, conviene conservar suxmlns="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
}
labeles el texto visible;route, adónde lleva;iconPath, el trazo del icono (sólo el atributod: el resto del<svg>es igual en todos).target?es opcional (el signo?): las opciones internas no lo necesitan.Targetes 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 faltalabelo 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:
split('-')parte el texto en trozos por cada guion.filter(...)descarta los trozos vacíos (guiones al principio, al final o repetidos).map(...)pone la primera letra en mayúscula y el resto en minúscula.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:trackes 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¶
- Identifica qué cambia entre las copias (aquí, el trazo y el texto) y qué se repite (todo lo demás).
- Crea una interfaz con lo que cambia (
label,route,iconPath...). Lo que no siempre aparece, como opcional (target?). - Para un valor que sólo admite unas pocas opciones, usa una unión de
literales (
'_self' | '_blank'). - Crea un arreglo tipado (
: TopMenuOption[]) en un archivo de constantes. Los textos que se repiten, como constantes. - Si un dato se puede derivar de otro (la etiqueta de la ruta), escribe una
función en
utils/en vez de duplicarlo. - En el componente, asigna el arreglo a una propiedad de la clase.
- En la plantilla, un
@forcontracksobre 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
tracken el@for: Angular exige indicar cómo identificar cada elemento. [d]en vez de[attr.d]: en un SVG enlazas atributos.- Escribir
targetcomo texto libre (string) en lugar de una unión: un error de tipeo ('_blank ') pasa sin aviso. - Usar
routerLinkcon una dirección externa: Angular la trata como una ruta interna y no navega al sitio. - Un
trackque 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.
4. ¿Cuándo usarías routerLink y cuándo href?¶
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
<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.#contentes su nombre (una variable de referencia de plantilla), ylet-optiones una variable que la receta recibe al dibujarse.<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.ngTemplateOutletes la directiva que los une. Dice "dibuja esta plantilla aquí". Su valor es la receta:[ngTemplateOutlet]="content".ngTemplateOutletContextson 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">
hrefes la dirección: un enlace normal, que el navegador carga entero.targetviene de los datos:_blankabre otra pestaña. Si una opción no traetarget, 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 unngTemplateOutletlo pida. let-optionno tiene tipo: dentro de la plantilla,optiones de tipoany. 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¶
- En los datos, cada opción trae
routey, si hace falta,target. - En la clase, un método
isExternal(option)que decide si la dirección es externa. - En la plantilla, un
@if (isExternal(option)) { … } @else { … }con dos etiquetas<a>. - En la externa,
[attr.href],[attr.target]yrel="noopener noreferrer". - En la interna,
[routerLink], sinhref. - 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 }" />. - Importar
RouterLink,RouterLinkActiveyNgTemplateOutleten elimportsdel componente.
Errores comunes¶
- Usar
routerLinkcon una dirección externa: se convierte en una ruta de la app y no lleva al sitio. - Un solo
<a>conrouterLinky[attr.href]condicionales:routerLinkgana y elhrefexterno desaparece. - Olvidar
NgTemplateOutletenimports: la plantilla compartida no se dibuja. - Olvidar
$impliciten el contexto:let-optionqueda 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¶
1. ¿Por qué routerLink no sirve para un enlace a otro sitio?¶
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:
#capitalInputes un nombre para la caja de texto dentro de la plantilla (una variable de referencia): así se lee su valor concapitalInput.value.- 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). handleSearchrecorta los espacios de los lados contrim()y emite el valor limpio. Así quien lo reciba nunca tiene que limpiarlo.- El texto de ayuda (
placeholder) se arma con el criterio que llega porsearchCriteria: 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 llevaony no coincide con ningún evento del DOM. Por eso no se llamasearch, 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 prefijohandleque 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 llamarsehandleSearchen 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.$eventes 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 suimports.
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¶
- 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. - 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)). - En ese método, limpia el valor (
trim()) y emítelo con.emit(...). - Crea el componente de lista aparte, también reutilizable.
- En cada pantalla, importa los dos en
importsy escribe sus etiquetas. - Pasa el dato hacia abajo con
[entrada]="valor"y escucha el aviso con(salida)="metodo($event)". - Agrega la ruta de la pantalla como hija del layout.
- Pon las funciones generales en
shared/utils/y un alias entsconfig.json.
Errores comunes¶
- No pasar la entrada obligatoria:
input.requiredobliga 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
onal nombre de una salida (onSearch = output<string>()): la documentación pide evitarlo. Los nombres cononse 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
$eventen 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.jsony 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}` },
},
}
rewritecambia el prefijo:/api/countries/capitals/Tokyollega a la API como/countries/v5/capitals/Tokyo.headerses 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¶
- Arrancar con la clave de demostración de la documentación de la API: la búsqueda responde 200 con datos de ejemplo.
- Arrancar con una clave inventada: responde 401. Eso prueba que el proxy sí manda la clave y que la API la está evaluando.
- Abrir la pestaña Red: la petición del navegador no lleva
Authorization.
Instructivo: esconder la clave de otra API¶
- Poner la clave en
.env(ignorado por git) y su nombre, sin valor, en.env.template. - Copiar
proxy.conf.mjsy ajustartarget,rewritey el nombre de la variable. - Registrar
proxyConfigenangular.json. - Poner una URL relativa en el entorno.
- Reiniciar
ng serve. - Probar con una clave inventada: debe dar 401.
- Para producción, repetir lo mismo en el servidor. En Nginx es un bloque
location ^~ /api/countriesconrewrite,proxy_passyproxy_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ó suindex.html. Causa típica:ng servese arrancó antes de agregarproxyConfig. Solución: reiniciarlo.- Poner la clave en
environment.ts. Queda dentro del JavaScript publicado. - Esperar que el proxy funcione en producción.
proxy.conf.mjssólo lo usang serve. Al publicar, otro componente tiene que hacer ese trabajo: en04-country-appes Nginx (nginx.conf.template). Sin él, la aplicación publicada recibe elindex.htmlen lugar de los datos.
Resumen¶
- La clave vive en el servidor; el navegador sólo conoce una URL propia.
- El proxy agrega
Authorization: Bearery reescribe el prefijo. - Un interceptor corre en el navegador, así que no protege secretos.
- Cambiar
angular.jsonexige reiniciarng 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,getdevuelveObservable<Object>ysubscribeda un error de «no overload matches this call».- La respuesta es un objeto, no un arreglo; la lista está en
data.objects. mapes 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 deK. Si solospaes seguro,Kdebe ser solo'spa'; con la unión de arriba también se exigeneng,frayjpn.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¶
- Definir la interfaz de la API y la interfaz propia.
- Escribir el mapper con un método estático público.
- En el servicio:
http.get<TipoDeRespuesta>(url).pipe(map(...)). - En el componente: tres señales (
isLoading,isError, resultado). - Suscribirse con
nextyerror; apagarisLoadingen los dos. - Leer el mensaje del error desde
error.error. - Pasar el resultado al componente de lista con
input.required().
Errores comunes¶
- Olvidar el tipo en
get. TypeScript infiereObjecty falla al suscribir. - Tratar la respuesta como arreglo. Si es un objeto, lo que existe es
response.data, noresponse[0]. - Ver
,000en un número. El argumento denumberpide tres decimales. - Leer el mensaje en
err.errors. El cuerpo está dentro deerr.error. - Tipar el error del componente como
HttpErrorResponsecuando el servicio ya lo tradujo. Lo que llega es unErrorcuyomessagees el texto. - Tipar
translationscomo{ [key: string]: string }. Cada idioma es un objeto concommonyofficial, no un texto.
Resumen¶
- El servicio devuelve el modelo propio, no la respuesta de la API.
get<Tipo>()tipa la respuesta;mapla 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 unErrorcuyomessagees el texto, concatchErrorythrowError. - 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
}),
})
paramses una función reactiva: cada vez que cambia lo que devuelve, elloaderse ejecuta de nuevo.undefinedenparamsdeja el recurso inactivo (idle): no hace ninguna petición. Un texto vacío ('') no cuenta como inactivo, y por eso se escribethis.query() || undefined.loaderrecibeparamsy devuelve una promesa. Tu servicio devuelve unObservable, así quefirstValueFromtoma su primer valor y lo convierte en promesa.- Lo que el
loaderlance (throw) acaba encountryResource.error(). NO_COUNTRIES_FOUND_ERRORes una constante decountry.constants.ts, junto aEMPTY_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 cuandohasValue()estrue.error()no lanza: se puede leer siempre.isLoading()indica que hay una petición en curso. La lista lo recibe en su entradaisLoadingy muestra el indicador de carga mientras estrue.errorMessageeisLoadingson entradas opcionales deListComponent(input<string | undefined>()einput<boolean>(false));countriessigue siendoinput.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¶
- Dejar el servicio igual; solo asegurarse de que lance
new Error(...). - Crear una señal con lo que el usuario busca.
- Crear el
resource:paramsdevuelve esa señal (oundefinedsi está vacía) y elloaderllama al servicio confirstValueFrom. - Quitar
isLoading,isErrory la señal de resultados; usarisLoading(),error()yvalue()del recurso. - En la plantilla, leer
value()solo conhasValue(). - Manejar el campo vacío en el manejador, con su propia señal.
- Borrar el
subscribeviejo (o dejarlo comentado como referencia).
Errores comunes¶
- Usar
requesten vez deparams. En Angular 22 no existe. - Leer
value()sinhasValue(). Con el recurso en error lanza una excepción y la plantilla entera deja de dibujarse. - Devolver
''enparams. El recurso no queda inactivo: ejecuta elloaderal 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¶
resourcejunta entrada, petición, resultado, error y carga en un solo objeto.paramsdecide cuándo se pide;undefinedlo deja inactivo.- El
loaderdevuelve una promesa;firstValueFromadapta elObservable. value()lanza en error: se lee conhasValue().- 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
rxResourcese acaba elfirstValueFrom: pasas elObservabletal cual. - Cancela la petición anterior. Si el usuario busca otra cosa mientras la
primera petición sigue en vuelo,
rxResourcese desuscribe y la petición HTTP se cancela. Conresource+firstValueFromla petición vieja seguía viva y solo se ignoraba su resultado. - La opción se llama
stream, noloader. Un tutorial deresourcecopiado aquí falla porqueloaderno 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.
resourcees la pieza básica: representa un valor que llega más tarde y funciona con promesas, así que sirve parafetch,async/awaito cualquier API del navegador, sin importar RxJS.rxResourcees el puente: si tu código ya habla enObservable(comoHttpClient), lo conectas sin convertir nada.- Hay también
httpResource, pensado para una peticiónGETsencilla 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¶
- Elegir la propiedad por la que se busca (una constante, como
PROPERTY_REGION). - En el componente, un campo:
countrySearch = createCountrySearch(PROPERTY_X). - En la plantilla, enlazar
(searchValue)acountrySearch.handleSearch($event). - Pasar a la lista
countrySearch.countries(),countrySearch.errorMessage()ycountrySearch.isLoading(). - Si la pantalla necesita algo que la función no cubre, no añadir opciones a
la función: escribir un
rxResourcepropio en ese componente.
Errores comunes¶
- Usar
loaderconrxResource. La opción se llamastream. - Llamar a
createCountrySearchdentro 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
firstValueFromen unrxResource. 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.rxResourcerecibe elObservabledel servicio constreamy cancela la petición anterior.- La lectura segura de
value()vive en uncomputed(), no en la plantilla. - El servicio no guarda estado reactivo; cada pantalla tiene el suyo. Un caché interno es distinto (Módulo C16).
resource,rxResourceyhttpResourcemuestran 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/ESa/by/FR, el recurso pide el país nuevo y cancela la petición anterior. ConngOnInitesto 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 devalue(), por la misma razón de C12: leervalue()con el recurso en error lanza una excepción.@let country = ...guarda el valor en un nombre corto para no repetircountryResource.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. Solomessagepuede 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 elloader, 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 yrole="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 enmessage, 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/ESdesde 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 unnavigationIdenhistory.statey el primero vale 1.
goBack() {
if (history.state?.navigationId > 1) {
this.location.back()
} else {
this.router.navigateByUrl('/country/by-capital')
}
}
- Un
<a>sinhrefno se alcanza con el teclado. ConrouterLinksí 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¶
- Declarar la ruta con parámetro (
'by/:code'). - Activar
withComponentInputBinding()enprovideRouter. - En el componente,
code = input.required<string>()con el mismo nombre que el parámetro. - Crear un
rxResourcecuyoparamsleathis.code()y cuyostreamllame al servicio. - Convertir la lista vacía en un
Error. - En la plantilla, un bloque por estado (carga, error, valor) y
hasValue()antes de leervalue(). Para el error,<app-not-found>con el texto y el enlace de esa pantalla. - Un botón de volver con
inject(Location)ylocation.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(). Elinput()queda sin valor y Angular lanza un error porque es obligatorio. - Nombres distintos entre el parámetro de la ruta (
:code) y elinput(). params: () => this.codeen vez dethis.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 eninput().rxResourcepide 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,alertybtn. - El error se muestra con un componente
not-foundgenérico, que recibe los textos y el enlace porinput(). - 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.
6. ¿routerLink o Location.back() para un botón de «volver»?¶
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ñalinputValue. Enter y el botón siguen llamando ahandleSearchcomo antes (Módulo C9).debounceTimees uninput()con valor por defecto: la constanteCLEAN_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:
- Lee el texto. Si está vacío, no programa nada y sale.
- Programa un
setTimeout: dentro de 500 ms, emite el texto al padre. - Registra con
onCleanupqué hacer antes de la próxima vuelta o cuando el componente se destruya: cancelar esesetTimeout.
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¶
- Una señal con el texto, actualizada desde
(keyup). - Un
effectque lee la señal, programa unsetTimeouty lo cancela enonCleanup. - El tiempo como
input()con valor por defecto en una constante. - 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
effectdentro 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 leeinputValue()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
effectcorre con cada cambio de la señal, programa unsetTimeouty lo cancela enonCleanupsi 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
queryno 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 unObservable<Country[]>. Quien lo llama (rxResource,subscribe) no sabe ni necesita saber si la lista vino de la red o delMap.ofenvuelve el valor en unObservableque lo emite y se completa.tapguarda,catchErrorno.tapcorre 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¶
- Un
Map<string, T>privado por cada tipo de consulta. - Normalizar la clave (
trim, minúsculas) y, si el método busca por varias propiedades, incluir la propiedad en la clave. - Antes de pedir: si
cache.has(key), devolverof(cache.get(key) ?? []). - En el
pipe, después delmap:tap((value) => cache.set(key, value)). - 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
setfuera antes delcatchErroro 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 unMapprivado. - 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;
hasygetantes de pedir;tapguarda después de recibir; los errores no se guardan. of(lista)devuelve lo cacheado con el mismo contratoObservable.- 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:
@forrepite el botón una vez por elemento deregions.trackle 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ñalquery, y elrxResourcedel 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 existequery.
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¶
- Un tipo de unión con las opciones válidas.
- Un arreglo constante con ese tipo, en el archivo de constantes.
- En el componente, un campo que apunte al arreglo (
regions = regions). - En la plantilla, un
@forcon un<button>que llame a la búsqueda. [class.clase]="actual() === opción"para marcar el botón activo.- 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-dangeryrow: ninguna existe en daisyUI ni en Tailwind (los colores sonbtn-error,btn-primary, etc., y la fila se hace conflex). El botón se ve bien porbtnybtn-outline, pero esas dos clases no hacen nada. Queda pendiente. track $indexen 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 propiedadregionsaunque elimportesté 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
handleSearchque 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') ?? '')
ActivatedRoutees «la ruta que está activa ahora»: sabe su URL, sus parámetros y sus datos.snapshotes 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 onullsi el parámetro no existe;?? ''convierte esenullen 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¶
- Inyectar
ActivatedRouteyRouter. - Iniciar la señal con
snapshot.queryParamMap.get('nombre') ?? ''. - En el método que busca, después de
query.set(...), llamar arouter.navigate([], { relativeTo, queryParams: { nombre: valor || null }, replaceUrl: true }). - Probar: buscar, recargar (debe seguir ahí) y copiar la URL en otra pestaña.
- Vaciar la búsqueda y comprobar que el
?query=desaparece de la dirección.
Errores comunes¶
- Olvidar el
?? ''.get()devuelvenullsi no hay parámetro, y la señal quedaría con un tipo mezclado. queryParamssinnullpara el valor vacío. Quedaría?query=en la URL. Connullse limpia.- Esperar que
snapshotse actualice. Es una foto: si la URL cambia con esta pantalla ya abierta (por ejemplo con Atrás),queryno se entera. ConreplaceUrlcasi 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í
handleSearchcambia las dos juntas. - El cuadro de texto queda vacío al entrar con
?query=madrid. La lista muestra resultados, peroSearchComponentno 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.snapshotlee el valor al entrar;Router.navigatelo escribe al buscar.replaceUrl: trueevita llenar el historial;nullborra el parámetro.- Hecho en la función genérica, lo heredan todas las pantallas que la usan.
- Con
snapshotla 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.