Angular Router: guards, resolvers y lazy loading
Una aplicación de Angular rara vez se resuelve en una sola pantalla. En la práctica hay un área pública, un panel privado, páginas que dependen de datos que todavía no tenemos y módulos que casi nadie visita.
Si todo eso se resuelve con navegación simple, la aplicación termina con tres problemas conocidos: pantallas vacías mientras llegan los datos, bundles iniciales enormous y lógica de autorización repartida dentro de los componentes.
El Router de Angular tiene mecanismos propios para cada uno de esos casos:
| Mecanismo | Para qué sirve |
|---|---|
loadComponent / loadChildren | Dividir la aplicación y descargar código solo cuando hace falta |
| Guards | Impedir la entrada o la salida de una ruta según una condición |
| Resolvers | Obtener los datos de la ruta antes de activar el componente |
La idea que hay detrás de los tres es la misma: el Router no solo cambia la URL, también decide qué código se descarga, si se permite entrar y qué datos existen antes de mostrar la pantalla.
Las rutas como configuración
Las rutas se declaran en un arreglo. Cada ruta describe un path, el componente que se muestra y, opcionalmente, los guards y resolvers asociados:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard'
},
{
path: 'login',
loadComponent: () =>
import('./pages/login/login.page').then((m) => m.LoginPage)
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () =>
import('./pages/dashboard/dashboard.page').then((m) => m.DashboardPage)
},
{
path: '**',
redirectTo: 'dashboard'
}
];
Ese arreglo se conecta con la aplicación mediante provideRouter, normalmente en un archivo de configuración propio:
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding())
]
};
withComponentInputBinding() es importante y lo veremos de nuevo con los resolvers: hace que los parámetros de la ruta y los datos resueltos lleguen al componente como inputs de Angular, en lugar de tener que leerlos del servicio ActivatedRoute dentro de ngOnInit.
Lazy loading: descargar solo lo necesario
Lazy loading significa que el código de una ruta se descarga en el momento en que se necesita, no en el bundle inicial.
Cargar un componente
loadComponent recibe una función que devuelve un import() dinámico. Cuando el usuario navega a esa ruta por primera vez, el navegador descarga ese archivo:
{
path: 'reports',
loadComponent: () =>
import('./pages/reports/reports.page').then((m) => m.ReportsPage)
}
import() devuelve una promesa, así que el .then() se utiliza para extraer el componente exportado desde el módulo.
El beneficio es directo: el bundle inicial contiene únicamente lo necesario para pintar la primera pantalla.
Cargar un conjunto de rutas
Cuando una sección tiene varias pantallas, lo habitual es agruparlas y cargarlas juntas con loadChildren:
{
path: 'admin',
canMatch: [adminGuard],
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES)
}
Y las rutas hijas viven en su propio archivo:
import { Routes } from '@angular/router';
export const ADMIN_ROUTES: Routes = [
{ path: '', redirectTo: 'users', pathMatch: 'full' },
{
path: 'users',
loadComponent: () =>
import('./pages/users/users.page').then((m) => m.UsersPage)
},
{
path: 'roles',
loadComponent: () =>
import('./pages/roles/roles.page').then((m) => m.RolesPage)
}
];
La ventaja de loadChildren frente a loadComponent es que la estructura de la sección se mantiene separada y el resto de la aplicación no necesita conocer sus rutas internas.
Precarga
Lazy loading tiene un coste: la primera visita a una ruta tarda más porque hay una descarga. Para reducirlo se puede activar la precarga:
import {
provideRouter,
withPreloading,
PreloadAllModules
} from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules),
withComponentInputBinding()
)
]
};
PreloadAllModules descarga en segundo plano, después de la carga inicial, los módulos que pueden necesitarse. La navegación sigue siendo diferida: el código se descarga por adelantado, pero no se ejecuta hasta que se navega.
Guards: decidir si se puede entrar
Un guard es una función que se ejecuta antes de activar una ruta. Si devuelve un valor falso o una redirección, la navegación se cancela y el componente nunca se crea.
La versión actual usa funciones puras en lugar de clases. Se declaran con CanActivateFn y reciben el contexto de la navegación:
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../core/auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.estaAutenticado()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
Hay dos resultados posibles:
truepermite la navegación.- Un
UrlTreela cancela y redirige.
Devolver un UrlTree en lugar de un false es la diferencia entre una pantalla en blanco y una redirección útil. Por eso conviene guardar la URL original en state.url: después del login, el usuario puede volver a la página que intentaba abrir.
inject() funciona dentro de un guard porque Angular crea la función dentro del contexto de inyección de la ruta. Esa es la diferencia práctica frente a escribir un guard como clase e inyectar dependencias en el constructor.
Evitar descargar el bundle
Hay un detalle importante. Si el guard va en la misma ruta que el componente, Angular necesita descargar el componente para poder evaluarlo y solo entonces descubre que el guard va a rechazarlo. El usuario espera una descarga para recibir una redirección.
Para evitarlo se usa canMatch, que se evalúa antes de cargar el módulo:
export const adminGuard: CanMatchFn = (route, segments) => {
const auth = inject(AuthService);
return auth.tieneRol('admin');
};
Con canMatch la secuencia es otra: primero se resuelve la condición, y solo si devuelve true se descarga el código de la sección. Es la opción correcta para decidir si una zona lazy debe descargarse.
Guard de salida
canDeactivate protege el caso contrario: impedir que el usuario salga de una pantalla con cambios sin guardar.
import { CanDeactivateFn } from '@angular/router';
import { FormGroup } from '@angular/forms';
export const cambiosGuard: CanDeactivateFn<FormGroup> = (form) => {
if (form.dirty) {
return confirm('Hay cambios sin guardar. ¿Quieres salir de todos modos?');
}
return true;
};
Y para proteger un grupo entero de rutas hijas se utiliza canActivateChild, que se aplica a todas las rutas que cuelgan de la ruta padre.
Resumen de guards
| Guard | Momento de evaluación | Caso de uso típico |
|---|---|---|
canMatch | Antes de cargar el módulo lazy | Ocultar secciones por rol o plan |
canActivate | Antes de activar la ruta | Exigir sesión iniciada |
canActivateChild | Antes de cada ruta hija | Proteger todas las pantallas de un área |
canDeactivate | Al intentar salir de la ruta | Avisar de cambios sin guardar |
Resolvers: datos listos antes de activar
Un resolver ejecuta una carga de datos y el router espera a que termine antes de crear el componente. Cuando la pantalla aparece, los datos ya están disponibles, así que no hace falta gestionar un estado de carga en cada vista.
Se declaran con ResolveFn y devuelven datos o un Observable de esos datos:
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from '../core/user.service';
export const userResolver: ResolveFn<string> = (route) => {
const users = inject(UserService);
const id = route.paramMap.get('id')!;
return users.obtenerPorId(id);
};
La ruta conecta el resolver con la propiedad resolve:
{
path: 'users/:id',
resolve: {
usuario: userResolver
},
loadComponent: () =>
import('./pages/user-detail/user-detail.page').then((m) => m.UserDetailPage)
}
Con withComponentInputBinding() activo, el componente recibe usuario como input, con el mismo nombre que la clave del objeto resolve:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-detail',
template: `
<h1>{{ usuario().nombre }}</h1>
<p>{{ usuario().email }}</p>
`
})
export class UserDetailPage {
usuario = input<Usuario>();
}
Sin esa opción de binding habría que leer los datos desde el servicio:
export class UserDetailPage {
private route = inject(ActivatedRoute);
ngOnInit(): void {
this.route.data.subscribe((data) => {
this.usuario = data['usuario'];
});
}
}
La versión con input es más corta y evita la suscripción manual.
La diferencia frente a leer datos en el componente
Lejos del resolver, lo habitual es cargar los datos dentro del componente:
ngOnInit(): void {
this.route.paramMap.subscribe((params) => {
this.loading = true;
this.users.obtenerPorId(params.get('id')).subscribe((user) => {
this.usuario = user;
this.loading = false;
});
});
}
Funciona, pero deja en el componente tres responsabilidades: leer la ruta, disparar la carga y gestionar los estados de carga y error. Con un resolver, la primera y la tercera salen del componente, que solo se ocupa de presentar el resultado.
El coste es que el resolver se ejecuta siempre que se entra a la ruta, así que conviene tener en cuenta dos cosas: no usarlo para datos globales que cambian pocas veces, y aplicar shareReplay si un mismo observable se consume desde varios resolvers.
Cómo encajan las tres piezas
El flujo completo de una navegación protegida con datos se puede resumir así:
1. El usuario navega a /admin/usuarios/42
2. canMatch -> decide si se descarga el módulo de admin
3. loadChildren -> descarga el código de la sección
4. canActivate -> valida la sesión
5. resolve -> obtiene el usuario 42
6. Activación -> se crea el componente con los datos ya disponibles
Ese orden es la razón por la que conviene colocar el control de acceso en canMatch y no en canActivate cuando se trata de una sección lazy: es el punto más temprano de la secuencia y el único que todavía no ha gastado una descarga de red.
Un ejemplo con las tres piezas, aplicado a una ruta real:
{
path: 'usuarios/:id',
canMatch: [adminGuard],
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
resolve: {
roles: rolesResolver
}
}
Errores frecuentes
Devolver false en vez de un UrlTree. El guard funciona, pero el usuario se queda en una pantalla vacía sin saber qué pasó. Redirigir siempre es más útil.
Colocar el guard después de loadComponent. En la práctica Angular evalúa el guard cuando ya ha cargado el componente, así que se pierde la ventaja del lazy loading y se descarga código que no se va a mostrar.
Esperar datos en el componente cuando ya existe un resolver. Si el componente carga datos y además hay un resolver configurado, la misma información se pide dos veces.
Olvidar withComponentInputBinding(). Los datos resueltos llegan correctamente, pero el componente no los recibe como inputs y el desarrollador termina buscándolos en ActivatedRoute.
Confundir precarga con carga inmediata. withPreloading no elimina el lazy loading, solo adelanta la descarga en segundo plano.
Conclusión
El Router de Angular separa tres responsabilidades que normalmente se mezclan:
loadComponentyloadChildrendeciden qué código se descarga, de forma diferida y solo cuando la ruta se visita.- Los guards deciden si se permite entrar o salir, y mediante
canMatchlo hacen antes de gastar una descarga. - Los resolvers deciden qué datos existen antes de mostrar la pantalla, eliminando estados de carga que cada componente tendría que repetir.
La combinación de los tres produce una aplicación donde la navegación deja de ser un simple cambio de URL y pasa a ser una decisión controlada: primero se valida el acceso, después se carga lo necesario y finalmente se renderiza con los datos resueltos.
© Arturo Juárez Monroy