NgModule confundía a casi todos los que se encontraban con Angular por primera vez. Vi a developers senior de React pasar dos días entendiendo por qué un componente declarado en FeatureModule no estaba disponible en otro FeatureModule. La respuesta involucraba SharedModule, forFeature(), y un grafo de dependencias completamente invisible a nivel de componente. Los standalone components arreglan esto eliminando la indirección.
Qué significa realmente Standalone
Un standalone component declara sus propias dependencias directamente en su array de imports. Sin wrapper de NgModule. Sin array de declarations en otro lugar. Si un componente necesita RouterLink, importa RouterLink. Si necesita CurrencyPipe, importa CurrencyPipe. El archivo del componente se autodescribe.
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AccountCardComponent } from './account-card.component';
import { Account } from './account.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
RouterLink,
AccountCardComponent
],
template: `
<h1>Dashboard</h1>
<a routerLink="/accounts">All accounts</a>
@for (account of accounts; track account.id) {
<app-account-card [account]="account" />
}
`
})
export class DashboardComponent {
accounts: Account[] = [];
}Nota que AccountCardComponent se importa directamente. No necesita estar en un shared module. El import en sí te dice: este componente usa AccountCardComponent. No se requiere rastrear a través de declaraciones de módulos. Nota que el array de imports lleva solo lo que el template realmente usa. Cualquier otra cosa es peso muerto, y el diagnóstico NG8113 de Angular te lo va a avisar.
Bootstrapping sin AppModule
Los proyectos nuevos generados con ng new ya no crean AppModule. Usan bootstrapApplication con un ApplicationConfig.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch(console.error);
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
};Lazy Loading por componente
Antes de standalone, la unidad más pequeña lazy-loadable era un módulo. Si querías hacer lazy-load de un solo componente de página, tenías que envolverlo en un módulo primero. Ahora usas loadComponent directamente.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'accounts',
loadChildren: () =>
import('./features/accounts/accounts.routes')
.then(m => m.ACCOUNTS_ROUTES)
}
];Para áreas de feature con múltiples rutas, sigo agrupándolas en un archivo de rutas y uso loadChildren. Eso mantiene la frontera de chunk a nivel de feature. Para páginas individuales que son genuinamente standalone, loadComponent es la opción correcta.
Providers con scope sin módulos
Los providers de NgModule eran la forma de hacer scope de servicios a un feature lazy-loaded. Sin NgModule, haces scope de providers a una ruta a través del array de providers en la definición de ruta.
{
path: 'lending',
loadComponent: () =>
import('./features/lending/lending.component').then(m => m.LendingComponent),
providers: [
LendingService,
{ provide: LENDING_API_URL, useValue: '/api/v2/lending' }
]
}En este ejemplo, el LendingService queda scoped al feature route de lending. Se crea la primera vez que la ruta se activa, y nada fuera del feature de lending puede inyectarlo. Un detalle: por default Angular no destruye el injector de una ruta cuando navegas fuera, así que el servicio y su estado siguen vivos durante toda la vida de la app. Angular 21.1 agregó withExperimentalAutoCleanupInjectors, que destruye el injector de una ruta cuando esa ruta sale del árbol de rutas activas, pero es experimental y hay que activarlo a mano. Hasta que lo actives, resetea el estado del feature explícitamente al entrar a la ruta en vez de asumir una instancia nueva.
Migrando un proyecto existente
El equipo de Angular provee schematics que automatizan la mayor parte de la migración. Ejecútalos en secuencia.
- ng generate @angular/core:standalone --mode=convert-to-standalone: convierte declarations a standalone
- ng generate @angular/core:standalone --mode=prune-ng-modules: elimina módulos vacíos después de la conversión
- ng generate @angular/core:standalone --mode=standalone-bootstrap: actualiza main.ts para usar bootstrapApplication
- Revisa el diff cuidadosamente para el setup de providers, especialmente si usas patrones forRoot()
- Actualiza TestBed: los standalone components usan imports en lugar de declarations en configureTestingModule
En Wonderlabs, llevé la plataforma de tarjetas de crédito de Angular 13 a Angular 15 en un monorepo. Las APIs standalone se volvieron estables en la 15.0 y el schematic de migración a standalone llegó en la 15.2, así que esa línea de versiones es donde una conversión como esta se vuelve práctica. El schematic maneja la mayor parte del trabajo. Lo que queda es configuración de providers en AppModule que necesita traducción manual a funciones provideX(). Planifica medio día por proyecto.
El impacto práctico
Después de migrar, el cambio más inmediato es la velocidad de onboarding. Un desarrollador nuevo puede entender de qué depende un componente leyendo el archivo del componente. Ya no necesitan saber en qué módulo vive, cuál SharedModule exporta CommonModule, y por qué RouterModule necesita ser importado en tres lugares. Esa carga cognitiva era real, y se ha ido.
El tamaño del build también mejora en proyectos donde las fronteras de módulo eran gruesas. El tree-shaking es más preciso cuando el grafo de imports vive a nivel de componente. En un área de feature que se apoya en un SharedModule demasiado amplio, el bundle inicial puede quedar notablemente más pequeño después de la migración solo por eliminar los imports que ese módulo arrastraba.
Los standalone components no agregaron nuevas capacidades. Eliminaron indirección. Ese es un problema de ingeniería más difícil de resolver, y es lo que más importa.
Referencias
- Angular standalone components. https://angular.dev/guide/components/importing
- bootstrapApplication API. https://angular.dev/api/platform-browser/bootstrapApplication
- Standalone migration schematic. https://angular.dev/reference/migrations/standalone
- Testing standalone components. https://angular.dev/guide/testing/components-basics