Standalone components en Angular: qué se gana al dejar atrás los NgModules
Durante años, todo componente Angular necesitó un NgModule que lo declarara. Crear una pantalla nueva implicaba, casi siempre, un archivo .module.ts adicional: un NgModule con declarations, imports y, si la ruta se cargaba de forma perezosa, un RouterModule.forChild con sus propias rutas. Ese archivo no aportaba lógica de negocio. Era plomería para que el compilador supiera qué directivas y componentes podía resolver un template. Desde que los standalone components llegaron en developer preview en Angular 14 y se estabilizaron en Angular 15, esa plomería pasó a ser opcional, y desde Angular 17 el CLI genera proyectos nuevos sin NgModules por defecto. Migramos varios proyectos internos a este modelo y esto es lo que cambió en la práctica, no en la teoría.
Qué declara realmente un componente
Un componente standalone se marca con standalone: true y declara sus propias dependencias directamente en el decorator, en el array imports:
@Component({
selector: 'app-pedido-detalle',
standalone: true,
imports: [NgIf, NgFor, RouterLink, PedidoLineaComponent],
templateUrl: './pedido-detalle.component.html',
})
export class PedidoDetalleComponent {
pedido = input.required<Pedido>();
}
La diferencia frente al modelo con NgModule no es solo sintáctica. Antes, para que este template pudiera usar *ngIf o <app-pedido-linea>, alguien tenía que haber declarado PedidoDetalleComponent en un NgModule que también importara CommonModule y declarara o importara PedidoLineaComponent. La resolución de qué está disponible en el template dependía de qué módulo contenedor eligiera importar, y ese módulo con frecuencia terminaba importando de más: un SharedModule que empezó con dos o tres piezas comunes y terminó arrastrando media docena de módulos de terceros que la mayoría de sus consumidores no necesitaban, simplemente porque en algún momento alguien los agregó ahí para no tocar cada módulo de feature por separado.
Con standalone, esa pregunta se contesta mirando un solo archivo: el array imports del propio componente. Si algo no está ahí, no está disponible en el template, sin importar qué otro módulo de la aplicación lo haya importado en otro lugar. El árbol de dependencias deja de heredarse de un contenedor y pasa a declararse por componente. Esto vuelve más largo el array de imports de componentes que usan varias directivas comunes -NgIf, NgFor, AsyncPipe- porque cada uno tiene que pedirlas explícitamente en vez de recibirlas gratis vía CommonModule en el módulo padre. A cambio, revisar qué usa un componente ya no exige rastrear la cadena de módulos que lo envuelven.
Lazy loading por ruta sin módulo intermedio
El cambio que más simplificó nuestra configuración de rutas fue loadComponent. Antes, una ruta perezosa apuntaba a un NgModule de feature:
{
path: 'pedidos',
loadChildren: () => import('./pedidos/pedidos.module').then(m => m.PedidosModule),
}
Y ese PedidosModule existía casi exclusivamente para declarar sus propias rutas hijas y agrupar los componentes de la feature. Con componentes standalone, la ruta puede apuntar directamente al componente:
{
path: 'pedidos',
loadComponent: () => import('./pedidos/pedidos-lista.component').then(m => m.PedidosListaComponent),
children: [
{
path: ':id',
loadComponent: () => import('./pedidos/pedido-detalle.component').then(m => m.PedidoDetalleComponent),
},
],
}
El chunk que Angular genera para esa ruta perezosa contiene exactamente el componente y sus dependencias declaradas, sin el nivel intermedio de un módulo que solo existía para agrupar rutas. Para servicios que antes se registraban con providedIn a nivel de un módulo de feature -pensados para vivir solo mientras esa sección de la aplicación estaba cargada-, el equivalente standalone es declarar el provider directamente en la definición de la ruta con la propiedad providers, que Angular instancia y destruye junto con esa rama del árbol de rutas.
Lo que no funcionó: migrar el SharedModule de golpe
La primera vez que migramos un proyecto real intentamos convertir el SharedModule completo en un solo paso: quitar el NgModule, convertir cada pieza que exportaba en standalone, y actualizar todos los componentes que lo importaban para que en su lugar importaran directamente las piezas sueltas que necesitaban. En papel parecía razonable. En la práctica, SharedModule era importado por casi cuarenta componentes de distintas features, y varios de ellos dependían de que ese módulo reexportara transitivamente cosas que no importaba directamente -un MaterialModule interno con media docena de módulos de Angular Material, por ejemplo-. Al eliminar el NgModule, esos componentes dejaron de compilar de golpe, y el diff de la migración se volvió imposible de revisar con cuidado: cuarenta archivos cambiados a la vez, sin manera clara de aislar qué rotura correspondía a qué componente.
Angular ofrece una función puente, importProvidersFrom, y componentes standalone pueden seguir importando un NgModule existente en su array imports sin problema -un NgModule es, en ese sentido, un import válido para un componente standalone-. La corrección fue exactamente eso: dejar SharedModule vivo, migrar los componentes de feature uno por uno a standalone mientras seguían importando SharedModule tal cual, y solo al final, con todos los consumidores ya migrados y visibles en componentes individuales, desarmar SharedModule en piezas sueltas porque en ese punto ya sabíamos exactamente qué componente necesitaba qué pieza. La migración incremental tardó más calendario que el intento de golpe, pero cada paso individual era revisable y reversible, y ninguno rompió la aplicación en producción.
Cuándo todavía conviene un NgModule
No migramos todo. Dos casos concretos donde mantuvimos NgModules deliberadamente:
Librerías internas que empaquetan varios componentes, directivas y pipes relacionados para que otros equipos los consuman con un solo import agrupado -un módulo de componentes de UI corporativos, por ejemplo- siguen teniendo sentido como NgModule, porque el punto de esa librería es justamente ofrecer una unidad de importación agrupada, y convertirla en una lista de imports individuales le traslada al consumidor una decisión que la librería existe para resolverle.
Código heredado que todavía depende de patrones ligados a NgModule -por ejemplo, providers registrados a nivel de módulo de feature con lógica de inicialización en el constructor del módulo mismo- no se beneficia de una migración apresurada. Convertir ese código a standalone sin antes entender por qué ese provider se registró ahí y no en root puede cambiar silenciosamente cuándo se crea y destruye esa instancia, y ese es exactamente el tipo de regresión sutil que un test de UI no detecta pero que aparece semanas después como un bug de estado compartido entre pantallas que no debería compartir nada.
El criterio que aplicamos ahora
Para proyectos nuevos, no hay pregunta: standalone desde el primer componente, sin NgModules salvo que una librería de terceros todavía los exija. Para proyectos existentes, migramos feature por feature, dejando que los componentes standalone convivan con los NgModules que todavía no tocamos -Angular soporta esa convivencia sin fricción-, y reservamos el desarmado de módulos compartidos grandes para el final, cuando ya vimos con claridad qué necesita cada consumidor. La ganancia real no es menos código en cada componente individual -el array de imports explícito a veces es más largo que el import implícito de antes-, sino que la pregunta "¿de dónde viene esto que usa mi componente?" tiene ahora una sola respuesta posible: el propio componente.