Skip to content

🚦 Clase 9 — Routing avanzado (query params, wildcard, lazy loading)

📅 2026-07-25 · 🗂️ Proyecto: app-routing (destino en este repo: 02-Ejercicios/app-routing) · 🎯 Profundizar Routing: query params, página 404 (wildcard), rutas hijas (dashboard) y Lazy Loading.

⬅️ Viene después de Clase 8 (Routing, 2026-07-18).

🎯 Qué aprendí

  • Query params ([queryParams]): a diferencia de un parámetro de ruta (:id), son información extra después del ? en la URL, sin que la ruta lo declare — se leen igual que :id, con @Input() + withComponentInputBinding().
  • Página 404 con la ruta wildcard (path: '**'), siempre al final del array de rutas para no "tapar" a las demás.
  • Rutas hijas con dashboard (children: [...]) y la diferencia entre routerLink relativo ("perfil") y absoluto ("/perfil") dentro de una ruta anidada.
  • Route Guards: CanActivate (authGuard, protege /dashboard antes de entrar) y CanDeactivate (puedeSalirGuard, confirma antes de salir de /editar-perfil con cambios sin guardar) — y su límite frente a window:beforeunload.
  • inject() como forma funcional de pedir dependencias (Auth, Router) dentro de un guard, que es una función suelta sin constructor.
  • Lazy Loading (loadComponent/loadChildren): cargar el código de una ruta solo cuando el usuario navega a ella, confirmado con ng build (chunks separados).

El profe retomó Routing en una clase nueva (Clase 9) con un proyecto distinto y más simple (app-routing, en su máquina) para cubrir temas que no se vieron en la Clase 8: query params, página 404 (ruta wildcard) y Lazy Loading. Como el profe no sube este proyecto al repo del curso, se recreó una versión propia en 02-Ejercicios/app-routing para poder correr y practicar en paralelo (ver sección correspondiente más abajo).

🆕 Proyecto de ejercicios en vivo: app-routing

Estructura de componentes vista en el explorador del profe: contacto, dashboard (con sub-componentes perfil y ajustes), inicio, pagina-no-encontrada, producto (singular, con :id), productos (plural, listado).

html
<!-- inicio.html -->
<h1>Bienvenido a la tienda</h1>

<a [routerLink]="['/producto', 5]">Ver Producto 5</a> <br>

<a [routerLink]="['/producto', 12]">Ver Producto 12</a> <br>

<a [routerLink]="['/productos']" [queryParams]="{categoria: 'ropa'}">Ver Ropa</a>

<!-- /producto/12    http://localhost:4200/productos?categoria=ropa -->
  • Primeros dos links: mismo patrón de array en [routerLink] ya visto en app-semana04 (['/producto', 5]/producto/5), aquí con la ruta en singular producto/:id en vez de productos/:id.
  • Tercer link (/productos + [queryParams]): a diferencia de :id (que es parte de la ruta, path: 'producto/:id'), un query param es información extra que va después del ? en la URL, sin que la ruta lo tenga que declarar. [queryParams] recibe un objeto ({ clave: valor }) con property binding [ ]; Angular lo convierte en ?clave=valor y lo pega a la URL que arma [routerLink].
  • [routerLink]="['/productos']" + [queryParams]="{categoria: 'ropa'}"http://localhost:4200/productos?categoria=ropa. La ruta sigue siendo /productos (la misma para cualquier categoría); es la vista destino (Productos) la que puede leer categoria de la URL para filtrar qué mostrar.

📝 En una captura anterior el valor era {categoria: 'ropa5'} — el profe lo corrigió a 'ropa' (coincide con el comentario ?categoria=ropa y con lo que se ve luego en el navegador: "Categoria filtrada : ropa").

🖥️ Resultado en el navegador (lado de lectura)

Al hacer clic en "Ver Ropa", la vista Productos muestra el valor leído de la URL:

Inicio
Contacto

Categoria filtrada : ropa
  • Confirma que el query param sí llega al componente Productos y se usa para filtrar/mostrar algo en pantalla — pero todavía no se vio el código que lo lee (si usa ActivatedRoute.queryParamMap, un input() de query param, o alguna otra forma). Se completa esta sección en cuanto aparezca producto.ts/productos.ts.

En una vista posterior de inicio.html en el navegador del profe, la pantalla de Inicio se ve así (confirma el <nav> de app.html con Inicio/Contacto arriba, y el tercer link ahora dice "Ver Laptos" en vez de "Ver Ropa"):

Inicio
Contacto

Bienvenido a la tienda

Ver Producto 5
Ver Producto 12
Ver Laptos

📝 "Ver Laptos" — probablemente el profe cambió el texto/categoría del tercer link (de ropa a algo como laptops) y quedó con una errata (falta la segunda "p"). Falta ver el inicio.html actualizado para confirmar el nuevo valor exacto de [queryParams] y corregir el texto en el proyecto de práctica.

📞 contacto.html

Navegando a /contacto (vía el link "Contacto" del <nav> de app.html), se ve:

Inicio
Contacto

Contáctanos

Escribenos a contacto@gmail.com.pe

💡 Un correo (contacto@gmail.com.pe) escrito directo en el template como texto plano — en Angular, el signo @ a inicio de un bloque (@if, @for...) es sintaxis de control flow, pero dentro de texto normal no genera conflicto; solo hay que tener cuidado si el @ queda justo donde Angular podría interpretarlo como el inicio de un bloque. En el proyecto de práctica se escapó como &#64; por seguridad, aunque en medio de una oración normalmente no hace falta.

🛠️ Proyecto de práctica local: 02-Ejercicios/app-routing

Como el profe aún no sube su app-routing al repo, se recreó una versión propia (mismos nombres de componentes: inicio, contacto, producto, productos, dashboard, pagina-no-encontrada) para poder correr y tocar el ejercicio ya mismo. No es una copia del código del profe — es una reconstrucción con lo documentado arriba, para practicar en paralelo. Se ajusta/corrige en cuanto se vea el código real.

  • app.routes.ts — las rutas (incluye path: '**' para la página 404, ver siguiente sección) y productos reutilizado como en app-semana04, más dashboard con children: [perfil, ajustes] (código real del profe, ver abajo).
  • dashboard.html<nav> + <router-outlet>, con ambos links corregidos a relativos (routerLink="perfil" / routerLink="ajustes", sin /), a diferencia del /perfil absoluto que quedó en la captura del profe (ver el ⚠️ más abajo).
  • app.config.tsprovideRouter(routes, withComponentInputBinding()).
  • producto.ts@Input() id?: string (igual que Productos en app-semana04).
  • productos.ts@Input() categoria?: string: se probó que withComponentInputBinding() no solo bindea parámetros de ruta (:id), también bindea query params al @Input() con el mismo nombre — sin tocar ActivatedRoute para nada. Es la forma más simple de leer ?categoria=ropa y explica el "Categoria filtrada : ropa" del navegador.
zsh · app-routing
$ cd 02-Ejercicios/app-routing
$ npm start   // → http://localhost:4200

🧪 Tip de entrevista: ¿withComponentInputBinding() solo sirve para :id en la ruta? → No — también bindea query params y datos de resolve a @Input()s del mismo nombre. Por eso categoria en la URL (?categoria=ropa) puede llegar directo a @Input() categoria sin inyectar ActivatedRoute.

🧪 Tip de entrevista: ¿Diferencia entre un parámetro de ruta (:id) y un query param (?categoria=...)? → El parámetro de ruta es parte de la URL fija (la ruta declara path: 'producto/:id', y sin ese segmento la ruta ni siquiera matchea); el query param es opcional y no forma parte del path — sirve para filtros, orden, paginación, etc., y la misma ruta funciona con o sin él.

📝 Este proyecto vive solo en la máquina del profe (~/angular/app-routing, terminal Git Bash de Windows) — no hay código propio que copiar todavía, solo transcripción de lo mostrado en pantalla. La carpeta pagina-no-encontrada/ vista en su explorador se reconstruyó en el proyecto de práctica de abajo, con una ruta wildcard.

🗂️ dashboard con children: perfil y ajustes

Código real visto en app.routes.ts del profe (a diferencia de la ruta wildcard más abajo, que es una adición propia para practicar):

ts
// app.routes.ts
import { Routes } from '@angular/router';
import { Inicio } from './inicio/inicio';
import { Contacto } from './contacto/contacto';
import { Producto } from './producto/producto';
import { Productos } from './productos/productos';
import { Dashboard } from './dashboard/dashboard';
import { Perfil } from './dashboard/perfil/perfil';
import { Ajustes } from './dashboard/ajustes/ajustes';

export const routes: Routes = [
    { path: '', component: Inicio },
    { path: 'contacto', component: Contacto },
    { path: 'producto/:id', component: Producto },
    { path: 'productos', component: Productos },

    {
        path: 'dashboard', component: Dashboard,
        children: [
            { path: 'perfil', component: Perfil },
            { path: 'ajustes', component: Ajustes },
        ]
    },
];
  • Mismo patrón de rutas hijas ya documentado con app-semana04 (DashBoardLayout/DashBoardInicio/DashBoardConfig): Dashboard necesita su propio <router-outlet> para pintar Perfil o Ajustes según la URL (/dashboard/perfil o /dashboard/ajustes).
  • A diferencia del ejemplo de app-semana04, aquí no hay una ruta {path: ''} dentro de children — entrar a /dashboard a secas no matchea ningún hijo (queda en blanco dentro del layout, salvo que el profe agregue una en el siguiente paso).
  • El explorador de archivos muestra perfil/ y ajustes/ como subcarpetas dentro de dashboard/ (no en la raíz de app/, ni en una carpeta component/ aparte) — otra forma válida de agrupar componentes que pertenecen a una sola sección.

dashboard.html / dashboard.ts — el layout con su propio <router-outlet>

ts
// dashboard.ts
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-dashboard',
  imports: [RouterLink, RouterOutlet],
  templateUrl: './dashboard.html',
  styleUrl: './dashboard.css',
})
export class Dashboard {}
html
<!-- dashboard.html -->
<nav>
    <a routerLink="/perfil">Perfil</a>
    <a routerLink="ajustes">Ajustes</a>
</nav>

<router-outlet></router-outlet>

⚠️ Error real: el link de "Perfil" quedó con /perfil (con / inicial → ruta absoluta, resuelve desde la raíz del sitio: http://localhost:4200/perfil, que no existe como ruta — cae en la página 404). El de "Ajustes" quedó sin / (ajustes, ruta relativa a donde está el link) → como este <nav> vive dentro de Dashboard (montado en /dashboard), resuelve correctamente a /dashboard/ajustes. Corrección: ambos deberían ser relativos, routerLink="perfil" y routerLink="ajustes" (sin /), para que los dos apunten dentro de /dashboard/....

🧪 Tip de entrevista: ¿Qué diferencia hay entre routerLink="perfil" y routerLink="/perfil"? → Sin / es una ruta relativa al segmento actual de la URL (útil dentro de children, para no repetir el prefijo del padre); con / inicial es absoluta, siempre parte desde la raíz del sitio — si el componente vive anidado (como Dashboard), un link absoluto a un hijo suyo (/perfil en vez de /dashboard/perfil) apunta a una ruta que no existe.

Al escribir varios <a> en líneas separadas dentro de un <nav> (tanto en app.html como en dashboard.html), el navegador los mostró todos pegados: InicioContactoProductosDashboard, sin espacio entre ellos.

html
<!-- ❌ así se ven pegados -->
<nav>
  <a routerLink="">Inicio</a>
  <a routerLink="/contacto">Contacto</a>
</nav>
  • Causa: Angular, al compilar la plantilla, elimina el espacio en blanco "insignificante" entre elementos (saltos de línea + indentación no cuentan como texto real) — es una optimización por defecto (equivalente a preserveWhitespaces: false). El salto de línea que se ve en el editor no sobrevive a la compilación.
  • Corrección: agregar un separador explícito entre los links (texto, |, o un <br>), como ya se hacía en app-semana04:
html
<!-- ✅ con separador explícito -->
<nav>
  <a routerLink="">Inicio</a> |
  <a routerLink="/contacto">Contacto</a> |
  <a routerLink="/productos">Productos</a> |
  <a routerLink="/dashboard">Dashboard</a>
</nav>

🧪 Tip de entrevista: ¿Por qué elementos en líneas separadas en el HTML pueden aparecer pegados en el navegador con Angular? → Porque Angular descarta el espacio en blanco insignificante (saltos de línea/indentación) al compilar la plantilla — el salto de línea del editor no es un espacio real para el HTML final. Hay que poner un separador explícito (texto, |, <br>) si se quiere espacio visible entre elementos inline.

🃏 Ruta wildcard (**) para página 404

En el proyecto de práctica se agregó, al final del array de rutas:

ts
// app.routes.ts
export const routes: Routes = [
    { path: '', component: Inicio },
    // ...el resto de rutas...
    { path: '**', component: PaginaNoEncontrada },   // 👈 siempre al final
];
  • path: '**' — comodín que matchea cualquier URL que no haya sido capturada por ninguna ruta anterior (/lo-que-sea, /asdf123, etc.).
  • Debe ir al final del array — el router prueba las rutas en orden y se queda con la primera que matchee; si '**' fuera la primera, capturaría todo y ninguna otra ruta se alcanzaría a evaluar nunca.

🧪 Tip de entrevista: ¿Por qué la ruta ** siempre va al final? → Angular Router evalúa las rutas en el orden del array y usa la primera coincidencia; ** matchea cualquier cosa, así que si estuviera antes "taparía" a todas las rutas siguientes, haciendo que nunca se muestren.

🗺️ Diagrama: el árbol de rutas completo (proyecto de práctica)

Resumen visual de todas las rutas de 02-Ejercicios/app-routing tal como quedaron al cierre de esta clase — perfil/ajustes siguen marcadas como pendientes porque están comentadas en el app.routes.ts real (ver sección de dashboard más arriba):

Diagrama del árbol de rutas de app-routing: la raíz routes (app.routes.ts) se ramifica en las rutas Inicio, Contacto (lazy), Producto con id, Productos, Formulario, Formulario reactivo, Editar perfil con canDeactivate, Dashboard con canActivate, y la ruta wildcard 404 al final; dashboard tiene dos hijas pendientes, perfil y ajustes, marcadas como comentadas en el código

  • '**' siempre al final — se ve literalmente como el último hijo de routes en el diagrama, confirmando la regla de la sección anterior.
  • perfil/ajustes en naranja punteado — no es que no existan: los componentes están creados, pero la línea children: [...] de dashboard quedó comentada en el código mientras se probaba el guard canActivate. El diagrama lo marca como pendiente en vez de mostrarlo como si ya estuviera activo.

⚡ Lazy Loading (carga perezosa)

El profe explicó el tema y lo aplicó en vivo a la ruta contacto de app-routing. Hasta ese momento, todos los componentes de las rutas (Inicio, Contacto, Producto, Productos, Dashboard...) se importaban arriba de app.routes.ts con un import normal — eso significa que todos viajan juntos en el mismo bundle (main.js) desde el primer segundo, aunque el usuario solo visite /.

Lazy Loading es cargar el código de una ruta solo cuando el usuario navega a ella, en vez de meterlo todo en el paquete inicial de la aplicación.

🎯 Para qué sirve

  • App más rápida al arrancar: el navegador descarga menos JavaScript en la primera carga (main.js más chico) — solo lo que se necesita para pintar la ruta inicial (/).
  • El código de rutas que el usuario puede que nunca visite (p. ej. Dashboard, si es un panel de admin que solo algunos usuarios abren) no se descarga hasta que hace falta.
  • Es más notorio mientras más rutas/páginas tenga la app — en un proyecto de 4-5 componentes como este no se nota mucho, pero en una app real con decenas de páginas es la diferencia entre un main.js de varios MB o uno pequeño.

🆚 Eager (actual) vs Lazy — comparación

Eager loading (lo que hay ahora)Lazy loading
Importimport { Producto } from './producto/producto' arriba del archivoNingún import estático — se usa import() dinámico dentro de la ruta
En la ruta{ path: 'producto/:id', component: Producto }{ path: 'producto/:id', loadComponent: () => import('./producto/producto').then(m => m.Producto) }
Cuándo se descarga el códigoTodo junto, al cargar la app (en main.js)Solo al navegar a esa ruta (un chunk .js aparte)
VerificaciónUn solo main.js grande en ng buildVarios archivos "lazy chunk" además de main.js

🧩 Sintaxis moderna: loadComponent (standalone)

Código real que puso el profe (convirtiendo contacto de eager a lazy):

ts
// app.routes.ts
{ path: 'contacto', loadComponent: () => import('./contacto/contacto').then(m => m.Contacto) },
  • Antes: import { Contacto } from './contacto/contacto'; arriba del archivo + { path: 'contacto', component: Contacto }eager, viaja en main.js.
  • Ahora: sin import estático de Contacto en app.routes.ts — el import() dentro de loadComponent es dinámico, y el bundler lo separa en su propio chunk.

Aplicado en el proyecto de práctica (02-Ejercicios/app-routing) y confirmado con ng build:

zsh · app-routing
$ ng build
Initial chunk files | Names         |  Raw size
chunk-WGOAFEWR.js   | -             | 113.40 kB
main-GRYY4BSK.js    | main          |  96.24 kB
 
Lazy chunk files    | Names         |  Raw size
chunk-ZQAZA6DJ.js   | contacto      |    361 bytes
  • El main.js inicial bajó frente al build anterior (todo eager) porque Contacto ya no viaja ahí — quedó en su propio lazy chunk (contacto, 361 bytes) que solo se descarga cuando el usuario navega a /contacto.
  • Así se ve en la práctica lo que antes era solo teoría: la tabla comparativa de arriba (eager vs lazy) — el build es la prueba.

🌐 Confirmación en el navegador (pestaña Network)

En el profe (con ng serve), la pestaña Network del DevTools mostró peticiones como:

DevTools · Network
component?c=src/app/dashboard/perfil/perfil.ts...
component?c=src/app/dashboard/ajustes/ajustes.ts...
component?c=src/app/pagina-no-encontrada/pagina-no-encontrada.ts...
chunk-TEGXLTFG.js
component?c=src/app/contacto/contacto.ts...   👈 aparece al entrar a /contacto
  • Confirma lo que se explicó arriba: el archivo de contacto no se pide de entrada, aparece en la lista justo cuando se navega a esa ruta — "trae contacto solo cuando lo necesito".
  • Matiz importante: estas peticiones component?c=... son el dev server de Angular (ng serve, basado en Vite/esbuild) sirviendo cada componente por separado bajo demanda — esto pasa en modo desarrollo para cualquier componente (eager o lazy) a medida que el navegador los va necesitando para pintar la ruta activa, no es exclusivo de loadComponent. Por eso también aparecen perfil/ajustes (rutas eager, sin loadComponent) en la lista: se pidieron cuando se navegó a /dashboard/perfil y /dashboard/ajustes, no porque sean lazy.
  • La prueba definitiva de que contacto es lazy (y las otras no) es la de ng build de arriba: ahí sí se ve la diferencia real entre quedar en el bundle inicial (perfil/ajustes/producto, dentro de main.js) vs quedar en su propio chunk (contacto, solo se genera porque tiene loadComponent). El Network tab en desarrollo es más difícil de usar para diferenciar eager de lazy porque Vite sirve módulos on-demand para ambos casos.

🧪 Tip de entrevista: ¿Sirve la pestaña Network en ng serve para confirmar lazy loading? → Con matices: en desarrollo (Vite/esbuild dev server) todos los componentes se piden bajo demanda según lo que el navegador va necesitando, sea la ruta eager o lazy — no es una prueba concluyente. La forma confiable es mirar el resultado de ng build (producción): ahí las rutas lazy generan chunks separados aparte de main.js, y las eager quedan dentro del bundle inicial.

Ejemplo de cómo se vería si se extendiera el mismo patrón al resto de rutas (no aplicado todavía, solo ilustrativo):

ts
// app.routes.ts — ejemplo, no implementado aún
import { Routes } from '@angular/router';
import { Inicio } from './inicio/inicio';   // Inicio sigue eager: es la home, se ve siempre

export const routes: Routes = [
    { path: '', component: Inicio },
    { path: 'contacto', loadComponent: () => import('./contacto/contacto').then(m => m.Contacto) },
    {
        path: 'producto/:id',
        loadComponent: () => import('./producto/producto').then(m => m.Producto),
    },
    {
        path: 'dashboard',
        loadComponent: () => import('./dashboard/dashboard').then(m => m.Dashboard),
        children: [
            { path: 'perfil', loadComponent: () => import('./dashboard/perfil/perfil').then(m => m.Perfil) },
            { path: 'ajustes', loadComponent: () => import('./dashboard/ajustes/ajustes').then(m => m.Ajustes) },
        ],
    },
];
  • loadComponent reemplaza a component — en vez de una referencia directa a la clase (que obliga a importarla arriba), recibe una función que retorna una Promise con el import dinámico (import('./ruta')).
  • El .then(m => m.Producto) saca la clase Producto del módulo que retorna el import dinámico (m es el archivo completo, m.Producto es la clase exportada).
  • Angular/el bundler (esbuild/Vite en Angular CLI) detecta estos import() dinámicos y genera un chunk separado para cada uno — ese archivo solo se descarga cuando el usuario efectivamente navega a esa ruta.

📦 loadChildren — para agrupar varias rutas hijas en un solo chunk

Cuando una sección tiene varias rutas hijas (como dashboard con perfil/ajustes), en vez de poner loadComponent en cada hija, se puede mover todo el grupo a su propio archivo de rutas y cargarlo de una sola vez con loadChildren:

ts
// dashboard/dashboard.routes.ts (archivo nuevo, dentro de la carpeta dashboard/)
import { Routes } from '@angular/router';
import { Perfil } from './perfil/perfil';
import { Ajustes } from './ajustes/ajustes';

export const DASHBOARD_ROUTES: Routes = [
    { path: 'perfil', component: Perfil },
    { path: 'ajustes', component: Ajustes },
];
ts
// app.routes.ts
{
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard').then(m => m.Dashboard),
    loadChildren: () => import('./dashboard/dashboard.routes').then(m => m.DASHBOARD_ROUTES),
},
  • loadChildren carga un array de rutas completo de forma perezosa — útil para no repetir loadComponent en cada hija cuando toda la sección se visita en conjunto.
  • Dentro de dashboard.routes.ts los imports de Perfil/Ajustes sí pueden ser estáticos (component: normal) porque ya están dentro del mismo chunk perezoso — la perezosidad ya la dio el loadChildren de afuera.

🧪 Tip de entrevista: ¿loadComponent vs loadChildren? → loadComponent carga un solo componente de forma perezosa para una ruta puntual; loadChildren carga un conjunto de rutas (normalmente de una sección completa, como un módulo de feature) de una vez, agrupando varias rutas hijas en el mismo chunk.

🧪 Tip de entrevista: ¿Cómo se verifica que el lazy loading realmente funciona? → Con ng build, revisando que aparezcan chunks separados además de main.js (uno por cada import() dinámico); o en el navegador, pestaña Network del DevTools, viendo que el .js de esa ruta se descarga recién al navegar a ella, no al cargar la app.

📌 loadComponent ya está confirmado y corriendo (ruta contacto, ver arriba). Pendiente: ver si el profe también muestra loadChildren (para dashboard con sus hijas perfil/ajustes) — por ahora esa parte sigue siendo solo el ejemplo ilustrativo de arriba, no código confirmado.

🔐 Servicio Auth (signal) — preparando Route Guards

El profe creó un servicio nuevo (auth.ts) con un signal para guardar si el usuario está logueado — la pieza típica que luego se usa para proteger rutas (Route Guards: CanActivate), aunque el guard todavía no se vio.

ts
// auth.ts
import { Injectable, signal } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class Auth {
  private logueado = signal(true); // para validar que el guard funciona, sin login real todavía

  estaLogueado(): boolean {
    return this.logueado();
  }

  iniciarSesion(): void {
    this.logueado.set(true);
  }

  cerrarSesion(): void {
    this.logueado.set(false);
  }
}
  • @Injectable({ providedIn: 'root' }) — registra el servicio como singleton global: una sola instancia compartida por toda la app (cualquier componente que lo inyecte recibe la misma), sin tener que agregarlo a ningún providers: manualmente.
  • private logueado = signal(true) — el estado real es privado; nada fuera de la clase puede hacer auth.logueado.set(...) directamente. Solo se puede leer vía estaLogueado() o cambiar vía los métodos iniciarSesion()/cerrarSesion() — un patrón de encapsulamiento: la clase controla cómo se modifica su propio estado. Arranca en true (no en false como en la primera versión) para poder probar que el guard deja pasar sin tener que armar un login real todavía — es un valor temporal de desarrollo, no el comportamiento final (una app real arrancaría deslogueada).
  • estaLogueado(): boolean — llama al signal como función (this.logueado()) para leer su valor actual y lo expone como un método normal (no como el signal en sí), así quien use el servicio no necesita saber que por dentro es un signal.
  • iniciarSesion() / cerrarSesion() — cambian el valor con .set(true)/.set(false); son la única forma de mutar logueado desde afuera de la clase.

🧪 Tip de entrevista: ¿Por qué exponer estaLogueado() como método en vez de dejar el signal público? → Encapsulamiento: si el signal fuera público, cualquiera podría hacer auth.logueado.set(true) sin pasar por iniciarSesion(), saltándose cualquier lógica futura (validar credenciales, llamar a una API, etc.) que se agregue ahí. Exponer solo métodos controla cómo se puede cambiar el estado.

🧪 Tip de entrevista: ¿Para qué se usaría normalmente un servicio así en routing? → Como base de un Route Guard (CanActivateFn): antes de entrar a una ruta protegida (p. ej. /dashboard), el guard inyecta Auth y llama a estaLogueado(); si es false, bloquea la navegación y redirige (p. ej. a / o a un login).

📌 Pendiente: no se vio todavía el CanActivateFn que use este servicio para proteger una ruta — solo el servicio en sí. Se completa esta sección en cuanto aparezca el guard.

🛡️ ng g guard — generando el Route Guard

El profe corrió el generador de Angular CLI para crear el guard:

zsh · app-routing
$ ng g guard auth
? Which type of guard would you like to create?
❯◉ CanActivate
 ◯ CanActivateChild
 ◯ CanDeactivate
 ◯ CanMatch

El CLI pregunta qué tipo de guard crear (con checkboxes, se puede elegir más de uno) — en la clase se dejó marcado solo CanActivate (el resaltado en la captura).

Tipo de guardCuándo se ejecutaUso típico
CanActivateAntes de entrar a una rutaBloquear el acceso si el usuario no está logueado (el caso de Auth)
CanActivateChildAntes de entrar a una ruta hija (children)Proteger todas las sub-rutas de una sección (p. ej. todo /dashboard/*) sin repetir el guard en cada hija
CanDeactivateAntes de salir de una rutaConfirmar "¿Salir sin guardar?" si hay un formulario con cambios sin guardar
CanMatchAntes de que el router decida si esa ruta aplicaSimilar a CanActivate, pero puede hacer que el router siga probando otras rutas si no matchea (útil combinado con lazy loading, para ni siquiera descargar el chunk si no hay permiso)
  • El CLI generó auth-guard.ts con la función authGuard: CanActivateFn.

🧪 Tip de entrevista: ¿Diferencia entre CanActivate y CanMatch? → Ambos deciden si se puede entrar a una ruta, pero CanActivate actúa después de que el router ya decidió que esa ruta es la que matchea (si lo bloquea, la navegación simplemente falla); CanMatch actúa durante la búsqueda de la ruta — si retorna false, el router puede seguir probando otras rutas con el mismo path (por ejemplo, mostrar una versión distinta de la misma URL según el rol del usuario) y evita cargar el chunk lazy de esa ruta si no hay permiso.

auth-guard.ts completo, conectado a dashboard

ts
// auth-guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from './auth';

export const authGuard: CanActivateFn = () => {
  const authService = inject(Auth);
  const router = inject(Router);

  if (authService.estaLogueado()) {
    return true;
  }

  router.navigate(['/']);
  return false;
};
ts
// app.routes.ts
import { authGuard } from './auth-guard';

export const routes: Routes = [
    // ...otras rutas...
    { path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
];
  • inject(Auth) / inject(Router)inject() es la forma funcional de pedir una dependencia (equivalente a ponerla en el constructor), necesaria aquí porque un guard CanActivateFn es una función suelta, no una clase con constructor. Ver la explicación completa (qué es, desde qué versión, y por qué se recomienda como default desde Angular 17) en 02-Conceptos.md § inject().
  • if (authService.estaLogueado()) — si el signal logueado está en true, el guard retorna true y el router deja continuar la navegación normalmente.
  • Si no está logueado: router.navigate(['/']) manda al usuario a la home (no hay página de login en este proyecto todavía) y el guard retorna false, bloqueando la navegación a /dashboard.
  • canActivate: [authGuard] — un array de guards en la ruta; Angular los evalúa antes de activar esa ruta (y sus children, salvo que además se agregue canActivateChild). Con logueado en false (valor inicial del signal), entrar a /dashboard directo redirige a / — hay que llamar antes a auth.iniciarSesion() (p. ej. desde un botón "Login" en algún componente) para poder entrar.

🧪 Tip de entrevista: ¿Por qué inject() en vez de un constructor en un guard funcional? → Un CanActivateFn (o CanActivate, CanMatch, etc. en su forma moderna) es una función, no una clase — no tiene constructor. inject() permite pedir dependencias del contexto de inyección de Angular dentro de una función, siempre y cuando se llame en el momento correcto (durante la ejecución del guard, que Angular invoca dentro de un contexto de inyección válido).

📌 El signal logueado arranca ahora en true (temporal, ver comentario en el código) para confirmar que el guard deja pasar sin armar todavía un login real. Falta un botón/acción que llame a auth.cerrarSesion() para probar también el otro camino: entrar a /dashboard deslogueado → bloqueado, redirige a /.

🚪 Segundo guard: puede-salir (¿CanDeactivate?)

El profe generó un segundo guard, esta vez con un nombre que sugiere el patrón clásico de "confirmar antes de salir de una página con cambios sin guardar":

zsh · app-routing
$ ng g guard puede-salir
? Which type of guard would you like to create?
 ◯ CanActivate
 ◯ CanActivateChild
❯◉ CanDeactivate
 ◯ CanMatch
CREATE src/app/puede-salir-guard.spec.ts (486 bytes)
CREATE src/app/puede-salir-guard.ts (134 bytes)

Mismo menú de tipos que con auth (ver tabla más arriba), esta vez con CanDeactivate resaltado en la selección.

El archivo generado (reconstruido en el proyecto de práctica) trae un esqueleto genérico — el CLI arma CanActivateFn para cualquier tipo de guard, sin importar cuál se haya seleccionado (CanActivate, CanDeactivate, etc.), así que hay que ajustar el tipo a mano según el guard que realmente se quiera:

ts
// puede-salir-guard.ts
import { CanDeactivateFn } from '@angular/router';

export interface ComponenteConCambiosSinGuardar {
  hayCambiosSinGuardar: () => boolean;
}

export const puedeSalirGuard: CanDeactivateFn<ComponenteConCambiosSinGuardar> = (component) => {
  if (component.hayCambiosSinGuardar()) {
    return confirm('Tienes cambios sin guardar, ¿Seguro que deseas salir?');
  }

  return true;
};
  • CanDeactivateFn<T> (no CanActivateFn) — el tipo correcto para un guard que decide si se puede salir de una ruta; T es la interfaz que describe el componente que se está por abandonar.
  • ComponenteConCambiosSinGuardar — la interfaz declara el contrato que debe cumplir cualquier componente protegido por este guard: tener un método hayCambiosSinGuardar() que el guard pueda llamar.
  • Firma distinta a CanActivate: un guard CanDeactivate recibe la instancia del componente como primer parámetro (component, para poder llamar component.hayCambiosSinGuardar()), mientras que CanActivate/CanActivateFn recibe (route, state) — la ruta activada y el estado del router, sin acceso al componente.

🧪 Tip de entrevista: ¿Para qué sirve específicamente CanDeactivate (a diferencia de CanActivate)? → Para interceptar la salida de una ruta, típicamente para preguntar "¿Salir sin guardar los cambios?" en un formulario — necesita poder consultarle al componente (no solo a la ruta) si tiene cambios pendientes, por eso recibe la instancia del componente como parámetro, a diferencia de CanActivate que solo mira la ruta/URL.

🗺️ Diagrama: los dos guards en acción

Con los dos guards ya vistos (authGuard en canActivate y puedeSalirGuard en canDeactivate), el flujo completo de una navegación protegida, de entrada y de salida:

Diagrama de flujo de los Route Guards en Angular: CanActivate se ejecuta cuando el usuario intenta entrar a /dashboard, preguntando si está logueado, dejando pasar o redirigiendo a inicio; CanDeactivate se ejecuta cuando el usuario intenta salir de /editar-perfil, preguntando si hay cambios sin guardar, mostrando un confirm y dejando salir o quedándose según la respuesta

  • canActivate (arriba) — se dispara antes de entrar a /dashboard; si estaLogueado() es false, ni siquiera llega a pintar Dashboard, redirige directo.
  • canDeactivate (abajo) — se dispara al intentar salir de /editar-perfil; solo si hayCambiosSinGuardar() es true aparece el confirm() — si el usuario cancela, la navegación se cancela y se queda en la misma página.

🔌 Conectado: ruta editar-perfil

Se creó un componente nuevo, EditarPerfil, que implementa la interfaz del guard para poder usarlo de verdad:

ts
// editar-perfil.ts
import { Component, HostListener } from '@angular/core';
import { ComponenteConCambiosSinGuardar } from '../puede-salir-guard';

@Component({
  selector: 'app-editar-perfil',
  imports: [],
  templateUrl: './editar-perfil.html',
  styleUrl: './editar-perfil.css',
})
export class EditarPerfil implements ComponenteConCambiosSinGuardar {
  formularioTocado = false;

  hayCambiosSinGuardar(): boolean {
    return this.formularioTocado;
  }

  // Cubre salir por FUERA de Angular: escribir otra URL, recargar o cerrar la
  // pestaña. CanDeactivate no alcanza a correr ahí porque no es navegación del Router.
  @HostListener('window:beforeunload', ['$event'])
  avisarAntesDeSalir(event: BeforeUnloadEvent): void {
    if (this.hayCambiosSinGuardar()) {
      event.preventDefault();
    }
  }
}
html
<!-- editar-perfil.html -->
<h2>Editar perfil</h2>

<label>
  Nombre:
  <input type="text" (input)="formularioTocado = true">
</label>

<p>¿Cambios sin guardar?: {{ formularioTocado }}</p>
ts
// app.routes.ts
import { EditarPerfil } from './editar-perfil/editar-perfil';
import { puedeSalirGuard } from './puede-salir-guard';

export const routes: Routes = [
    // ...otras rutas...
    { path: 'editar-perfil', component: EditarPerfil, canDeactivate: [puedeSalirGuard] },
];
  • implements ComponenteConCambiosSinGuardar — TypeScript obliga a EditarPerfil a tener el método hayCambiosSinGuardar() que el guard necesita llamar; es el "contrato" entre el guard y cualquier componente que quiera usarlo.
  • formularioTocado — se pone en true con (input)="formularioTocado = true" en el <input> de Nombre — cualquier tecla que se escriba ahí marca el formulario como "con cambios sin guardar".
  • canDeactivate: [puedeSalirGuard] — mismo patrón que canActivate, pero para la salida: antes de dejar /editar-perfil por cualquier otra ruta de Angular (clic en un routerLink, Router.navigate()), Angular llama al guard con la instancia de EditarPerfil; si hayCambiosSinGuardar() es true, aparece el confirm() personalizado ("Tienes cambios sin guardar, ¿Seguro que deseas salir?").

⚠️ Un límite importante: CanDeactivate no cubre TODO tipo de salida

Al probar escribiendo otra URL directamente en la barra de direcciones (en vez de hacer clic en un link), el confirm() personalizado no salía. La razón: escribir una URL nueva y dar Enter es una navegación completa del navegador (recarga la página desde cero) — Angular Router ni se entera, así que CanDeactivate nunca llega a ejecutarse. CanDeactivate solo intercepta navegación dentro de la SPA (clics en routerLink, Router.navigate()).

Para cubrir también ese caso (URL manual, recargar la página, cerrar la pestaña) se necesita un mecanismo del navegador, no de Angular: el evento window:beforeunload, con @HostListener (ver el código de arriba).

CanDeactivate (Angular)beforeunload (navegador)
Cuándo actúaNavegación dentro de la SPA (routerLink, Router.navigate())Cualquier salida de la página completa: URL nueva, recargar, cerrar pestaña/navegador
Mensaje del popupPersonalizable (confirm('tu texto'))Genérico, fijo por el navegador — no se puede personalizar (por seguridad, desde hace años ningún navegador moderno deja poner texto propio)
Para qué sirveConfirmar antes de cambiar de ruta dentro de la appConfirmar antes de perder la pestaña/página por completo

⚠️ El mensaje que muestra beforeunload es el que decide el navegador (en Chrome: algo como "¿Salir del sitio? Es posible que los cambios que hiciste no se guarden."), no el texto que se le pase a confirm() — es una limitación de todos los navegadores modernos, no un error del código.

🧪 Tip de entrevista: ¿CanDeactivate es suficiente para avisar siempre antes de perder cambios sin guardar? → No — solo cubre navegación dentro del Router de Angular. Para cubrir recargar la página, cerrar la pestaña, o escribir otra URL, hace falta además un listener de window:beforeunload. Son mecanismos complementarios, no uno reemplaza al otro.

🏋️ Ejercicios con solución

Ejercicio 1 — Proteger /formulario con authGuard

En el proyecto de práctica, la ruta formulario (ContactoForm) está sin proteger:

ts
{ path: 'formulario', component: ContactoForm },

Usando el mismo authGuard ya conectado a dashboard (canActivate: [authGuard]), protege también /formulario para que solo se pueda entrar si Auth.estaLogueado() devuelve true.

Ver solución
ts
// app.routes.ts
import { authGuard } from './auth-guard';

export const routes: Routes = [
    // ...otras rutas...
    { path: 'formulario', component: ContactoForm, canActivate: [authGuard] },
    // ...
];
  • Mismo patrón que dashboard: un array de guards en canActivate. Angular llama a authGuard antes de activar la ruta; si estaLogueado() es false, redirige a / y la navegación a /formulario se bloquea.
  • No hace falta tocar auth-guard.ts — el guard ya es genérico (no depende de la ruta).

Ejercicio 2 — Hacer lazy la ruta /productos

Siguiendo el patrón ya aplicado a contacto (de component: Contacto a loadComponent: () => import(...).then(m => m.Contacto)), convierte productos de eager a lazy:

ts
// estado actual (eager)
{ path: 'productos', component: Productos },
Ver solución
ts
// app.routes.ts
export const routes: Routes = [
    // ...otras rutas...
    {
        path: 'productos',
        loadComponent: () => import('./productos/productos').then(m => m.Productos),
    },
    // ...
];
  • Se quita el import { Productos } from './productos/productos'; estático de arriba del archivo (si ya no lo usa ninguna otra ruta) — el import ahora es dinámico, dentro de la función.
  • Se confirma con ng build: Productos debería pasar de estar dentro de main.js a aparecer como un lazy chunk propio en la tabla "Lazy chunk files", igual que pasó con contacto (361 bytes).

Ejercicio 3 — Leer un query param nuevo (orden) en Productos

Siguiendo el patrón ya documentado para categoria (@Input() categoria?: string + withComponentInputBinding(), sin tocar ActivatedRoute), agrega un segundo query param orden a la vista de productos: un link en inicio.html que además de filtrar por categoría, indique un orden (precio), y que Productos lo reciba y lo muestre.

Ver solución
html
<!-- inicio.html -->
<a [routerLink]="['/productos']"
   [queryParams]="{categoria: 'ropa', orden: 'precio'}">Ver Ropa por precio</a>

<!-- → http://localhost:4200/productos?categoria=ropa&orden=precio -->
ts
// productos.ts
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-productos',
  imports: [RouterLink],
  templateUrl: './productos.html',
  styleUrl: './productos.css',
})
export class Productos {
  @Input() categoria?: string;
  @Input() orden?: string;
}
html
<!-- productos.html -->
<p>Categoria filtrada : {{ categoria }}</p>
<p>Orden : {{ orden }}</p>
  • withComponentInputBinding() (ya activado en app.config.ts) bindea cualquier query param al @Input() del mismo nombre — por eso alcanza con declarar @Input() orden?: string para que llegue ?orden=precio, sin tocar el guard ni ActivatedRoute.
  • El objeto de [queryParams] puede tener varias claves; Angular arma la URL con & entre ellas (?categoria=ropa&orden=precio).

❓ Preguntas y respuestas

1. ¿Diferencia entre un parámetro de ruta (:id) y un query param (?categoria=...)?

El parámetro de ruta es parte de la URL fija (path: 'producto/:id'; sin ese segmento la ruta ni matchea); el query param es opcional y no forma parte del path — sirve para filtros, orden, paginación, etc., y la misma ruta funciona con o sin él.

2. ¿Por qué la ruta ** siempre va al final del array de rutas?

Angular Router evalúa las rutas en el orden del array y usa la primera coincidencia; ** matchea cualquier cosa, así que si estuviera antes "taparía" a todas las rutas siguientes, que nunca se alcanzarían a evaluar.

3. ¿Diferencia entre CanActivate y CanMatch?

Ambos deciden si se puede entrar a una ruta, pero CanActivate actúa después de que el router ya decidió que esa ruta es la que matchea (si bloquea, la navegación simplemente falla); CanMatch actúa durante la búsqueda de la ruta — si retorna false, el router puede seguir probando otras rutas con el mismo path, y evita descargar el chunk lazy de esa ruta si no hay permiso.

4. ¿CanDeactivate es suficiente para avisar siempre antes de perder cambios sin guardar?

No — solo cubre navegación dentro del Router de Angular (routerLink, Router.navigate()). Para cubrir recargar la página, cerrar la pestaña o escribir otra URL directo en la barra de direcciones, hace falta además un listener de window:beforeunload (con mensaje genérico, no personalizable). Son mecanismos complementarios.

5. ¿loadComponent vs loadChildren?

loadComponent carga un solo componente de forma perezosa para una ruta puntual; loadChildren carga un conjunto de rutas (normalmente una sección completa, como dashboard con perfil/ajustes) de una vez, agrupando varias rutas hijas en el mismo chunk.

6. ¿Por qué inject() en vez de un constructor en un guard funcional?

Un CanActivateFn (o CanDeactivateFn, CanMatchFn...) es una función, no una clase — no tiene constructor. inject() permite pedir dependencias del contexto de inyección de Angular dentro de una función, siempre que se llame en el momento correcto (durante la ejecución del guard, dentro de un contexto de inyección válido).

❓ Pendiente

  • [ ] Confirmar valor exacto de [queryParams] tras el cambio "Ver Laptos" (¿laptops?) y actualizar el proyecto de práctica.
  • [ ] Ver el código de producto.ts/productos.ts reales del profe para el lado de lectura del query param (confirmar si usa @Input() + withComponentInputBinding() como en el proyecto de práctica, o ActivatedRoute.queryParamMap).
  • [ ] Ver si el profe muestra loadChildren para dashboard (agrupar perfil/ajustes en un chunk), o si se queda solo con loadComponent por ruta.
  • [ ] Confirmar si el profe corrige el link /perfil (absoluto) que detectamos como error, o si queda así en su proyecto.
  • [x] Ver el CanActivateFn (Route Guard) que use el servicio Auth para proteger una ruta — hecho: auth-guard.ts conectado a dashboard con canActivate: [authGuard].
  • [ ] logueado quedó fijo en true para probar el guard sin login real — falta un botón/flujo que llame a auth.cerrarSesion() para probar también el camino bloqueado.
  • [ ] La ruta dashboard quedó sin children (perfil/ajustes comentados en app.routes.ts para simplificar mientras se probaba el guard) — falta decidir si se restauran combinadas con canActivateChild, o se dejan comentadas.
  • [x] Corregir el tipo del guard puede-salir a CanDeactivateFn<ComponenteConCambiosSinGuardar> y conectarlo — hecho: nueva ruta editar-perfil (componente EditarPerfil, que implements ComponenteConCambiosSinGuardar) con canDeactivate: [puedeSalirGuard].
  • [x] EditarPerfil.formularioTocado — hecho: <input (input)="formularioTocado = true"> en editar-perfil.html. Probado con navegador automatizado: escribir en Nombre + salir por un routerLink dispara el confirm() personalizado; cancelar bloquea la navegación. Funciona.
  • [x] Cubrir salida por URL manual/recarga/cierre de pestaña — hecho: @HostListener de beforeunload en EditarPerfil (con el mensaje genérico del navegador, no personalizable — ver sección de arriba).

📎 Apuntes relacionados

  • routing.md — Clase 8: bases de routing (app.routes.ts, provideRouter, routerLink, :id + withComponentInputBinding(), rutas hijas con app-semana04). Esta nota (Clase 9) es la continuación con temas nuevos.
  • 02-Conceptos.md — pendiente resumir aquí Lazy Loading y query params una vez que se confirme el resto del código real del profe.

➡️ Siguiente

Esperando más capturas/código de la clase para seguir completando Lazy Loading y el resto de pendientes.