Desarrollo web y móvil
Fundamentos de Angular: componentes standalone, signals y las API modernas
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Angular cambió de forma sustancial entre la era de los NgModules y las versiones actuales: los componentes son standalone por defecto, el estado vive en signals, las plantillas usan bloques de control flow integrados y la detección de cambios puede funcionar sin Zone.js. Esta guía enseña el framework tal como es hoy, desde un primer componente hasta la inyección de dependencias, HTTP, el enrutamiento, los formularios reactivos y el modelo de detección de cambios que lo une todo.
Lo que aprenderás
- Crear componentes standalone y entender cómo arranca una aplicación sin NgModules
- Enlazar datos en las plantillas con los bloques de control flow integrados y los signal inputs
- Modelar el estado con signals, valores computados y efectos, y saber cuándo RxJS sigue siendo la mejor herramienta
- Inyectar servicios y configurar HttpClient y el router con funciones de provider
- Construir y validar formularios reactivos, y explicar cómo decide la detección de cambios qué volver a renderizar
Si la última vez que usaste Angular fue en la época de NgModule, los decoradores @Input() y *ngFor, el framework que te vas a encontrar hoy es distinto en casi todos los archivos. Los componentes son standalone, el estado se guarda en signals, las plantillas usan una sintaxis de bloques para condicionales y bucles, los providers salen de funciones en lugar de metadatos de módulos y la detección de cambios puede prescindir por completo de Zone.js. La arquitectura (componentes, plantillas, inyección de dependencias, un router) se reconoce sin problema; la ergonomía, no.
Esta guía enseña la forma actual del framework, no la histórica. Empieza con un único componente, añade estado, servicios, HTTP y enrutamiento, y después explica el modelo de detección de cambios que hace que todo lo demás sea predecible. Todo lo que sigue da por hecho TypeScript, que Angular no se limita a tolerar sino del que depende: los metadatos de los decoradores, los tokens de inyección y los formularios tipados se apoyan en él.
La unidad de una aplicación: el componente
Un componente es una clase TypeScript con un decorador que la vincula a una plantilla y a unos estilos. No hace falta nada más: ningún módulo lo declara y ningún archivo barrel lo registra.
import { Component, signal, computed, input, output } from '@angular/core';
@Component({
selector: 'app-order-row',
template: `
<tr [class.selected]="selected()">
<td>{{ order().reference }}</td>
<td>{{ order().total | currency: 'EUR' }}</td>
<td><button type="button" (click)="remove.emit(order().id)">Remove</button></td>
</tr>
`,
styles: `.selected { background: var(--highlight); }`,
})
export class OrderRowComponent {
readonly order = input.required<Order>();
readonly selected = input(false);
readonly remove = output<string>();
}
Hay varias cosas que conviene señalar. input() y output() son funciones, no decoradores, y input.required<T>() hace que el compilador obligue a quien lo use a proporcionar el valor. Un input creado así es un signal: se lee llamándolo, y todo lo que se deriva de él se actualiza automáticamente. El selector es el aspecto que tiene la etiqueta allí donde se usa el componente; el array imports (omitido aquí porque la plantilla no usa otros componentes) es donde un componente standalone enumera los componentes, directivas y pipes que necesita su plantilla.
Aquí TypeScript pesa de verdad. Las interfaces describen las estructuras que cruzan las fronteras entre componentes, los genéricos tipan los inputs y las respuestas HTTP, y activar strict en tsconfig.json marca la diferencia entre que el compilador detecte un null y que te lo reporte el navegador.
Una aplicación arranca haciendo bootstrap de un componente raíz con una lista de providers:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
});
Plantillas: bindings y control flow con bloques
Las plantillas de Angular son HTML con cuatro formas de binding. La interpolación con dobles llaves muestra un valor como texto. Los corchetes enlazan una propiedad: [disabled]="isBusy()" fija la propiedad del DOM, no el atributo. Los paréntesis enlazan un evento: (click)="save()". Y la «banana en la caja» [(ngModel)] combina ambas direcciones, y no es más que azúcar sintáctico para un binding de propiedad más un binding de evento.
La lógica estructural usa ahora bloques integrados en lugar de las antiguas directivas con asterisco:
@Component({
selector: 'app-orders',
template: `
@if (orders().length > 0) {
<table>
@for (order of orders(); track order.id) {
<app-order-row [order]="order" (remove)="delete($event)" />
} @empty {
<tr><td>Nothing to show</td></tr>
}
</table>
} @else {
<p>Loading…</p>
}
@switch (status()) {
@case ('draft') { <app-draft-banner /> }
@case ('submitted') { <app-submitted-banner /> }
@default { <app-unknown-banner /> }
}
@defer (on viewport) {
<app-revenue-chart [data]="chartData()" />
} @placeholder {
<div class="skeleton"></div>
}
`,
imports: [OrderRowComponent, DraftBannerComponent],
})
export class OrdersComponent { /* … */ }
Los bloques forman parte del lenguaje de plantillas, no son directivas: no hay que importar nada, el compilador puede comprobar los tipos dentro de ellos y generan un resultado más pequeño y más rápido. La expresión track de @for es obligatoria, y tiene su importancia: le dice a Angular cómo emparejar los elementos entre renderizados para que pueda mover nodos del DOM en lugar de destruirlos y reconstruirlos. Hacer tracking por índice en una lista que se reordena es una fuente clásica de pérdida del estado de los campos del formulario. @defer es una capacidad realmente nueva: separa en otro chunk todo lo que contiene y lo carga ante un disparador, como entrar en el viewport, pasar el ratón por encima o un callback de inactividad.
Los pipes transforman un valor en la plantilla. currency, date, json, async y keyvalue cubren la mayoría de las necesidades, y un pipe puro propio es una forma barata de formatear sin añadir una llamada a método en cada pasada de detección de cambios.
Signals: estado que el framework puede seguir
Un signal es un contenedor de valor que registra quién lo lee. Cuando cambia, se notifica exactamente a esos lectores y no se vuelve a evaluar nada más.
import { Component, signal, computed, effect } from '@angular/core';
@Component({ /* … */ })
export class CartComponent {
readonly items = signal<CartItem[]>([]);
readonly coupon = signal<string | null>(null);
readonly subtotal = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0),
);
readonly total = computed(() =>
this.coupon() ? this.subtotal() * 0.9 : this.subtotal(),
);
constructor() {
effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
}
add(item: CartItem) {
this.items.update((current) => [...current, item]); // new array, not push
}
}
Un signal se lee llamándolo. Se escribe con set para sustituir el valor o con update para calcularlo a partir del anterior. computed deriva un valor de forma perezosa y lo cachea hasta que cambia alguna de sus dependencias, así que total solo se recalcula cuando cambian items o coupon. effect ejecuta efectos secundarios cuando cambian sus dependencias y sirve para sincronizarse con lo que está fuera de Angular (almacenamiento, una librería de gráficos, logging), no para escribir de vuelta en otros signals.
La regla que hace que todo esto funcione es la inmutabilidad. this.items.update(list => [...list, item]) produce un array nuevo y notifica a los lectores; this.items().push(item) muta el array en sitio y no notifica a nadie. Es la misma disciplina que se describe en fundamentos de JavaScript, solo que aquí el framework depende de ella.
Los signals también sustentan las API de query y de model: viewChild() y contentChild() devuelven signals del elemento encontrado, y model() crea un signal input enlazable en ambas direcciones.
Inyección de dependencias y servicios
El inyector de Angular es jerárquico: empieza en el componente y sube por los componentes padre hasta la raíz de la aplicación. Un servicio es una clase normal marcada como inyectable, y providedIn: 'root' la convierte en un singleton que el tree-shaking elimina si nadie lo inyecta.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class OrderService {
private readonly http = inject(HttpClient);
list(page: number) {
return this.http.get<Page<Order>>('/api/orders', { params: { page } });
}
create(draft: OrderDraft) {
return this.http.post<Order>('/api/orders', draft);
}
}
La función inject() ha sustituido en gran medida a la inyección por parámetros del constructor. Funciona en los inicializadores de campos, en funciones factory y dentro de los helpers provide*, y es componible: puedes escribir una pequeña función reutilizable que a su vez llame a inject(). La inyección por constructor sigue funcionando y es lo que seguirás viendo en archivos más antiguos.
Los tokens de inyección permiten inyectar cosas que no son clases (un objeto de configuración, una URL base, un feature flag), y proporcionar otra implementación para el mismo token es la forma de cambiar un servicio real por un fake en los tests o por un backend simulado en desarrollo.
HTTP, y adónde van los errores
HttpClient devuelve observables fríos: no se envía nada hasta que alguien se suscribe, y cancelar la suscripción cancela la petición. Configúralo una sola vez con provideHttpClient(withFetch()), que usa por debajo la implementación de fetch de la plataforma.
this.orders.list(1).subscribe({
next: (page) => this.items.set(page.content),
error: (error: HttpErrorResponse) => this.message.set(describe(error.status)),
});
En la API actual, los interceptores son funciones normales y son el sitio adecuado para las cabeceras de autenticación, los identificadores de correlación, la política de reintentos y el mapeo centralizado de errores:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(TokenStore).current();
return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};
A diferencia de fetch, HttpClient trata una respuesta que no sea 2xx como un error, así que la gestión de los códigos de estado va en el callback de error. Decidir qué significa cada código para el usuario (reintentar, volver a autenticarse, mostrar un mensaje de validación) merece hacerse de forma deliberada; la referencia de códigos de estado HTTP explica las diferencias, y diseño de API REST cubre el lado servidor del mismo contrato.
Las versiones más recientes añaden una API de resource que envuelve una carga asíncrona en signals: expone el valor, el estado de carga y el error como signals, y vuelve a lanzar la petición cuando cambia su signal de petición. Elimina buena parte del boilerplate de «carga esto cuando cambie aquello», y vale la pena consultarla en la documentación actual antes de implementarlo a mano.
Enrutamiento
Las rutas son un array normal, y la carga diferida es un import dinámico:
export const routes: Routes = [
{ path: '', component: HomeComponent, title: 'Home' },
{
path: 'orders',
loadChildren: () => import('./orders/routes').then((m) => m.ORDER_ROUTES),
canActivate: [authGuard],
},
{ path: 'orders/:id', loadComponent: () => import('./orders/detail').then((m) => m.OrderDetailComponent) },
{ path: '**', component: NotFoundComponent },
];
loadComponent y loadChildren dividen el bundle en la frontera de la ruta, que es la decisión de rendimiento con más impacto en una aplicación grande. Los guards son funciones que devuelven un booleano, un UrlTree o un observable de cualquiera de los dos. Con withComponentInputBinding() activado, los parámetros de ruta, los parámetros de consulta y los datos resueltos se enlazan directamente a los inputs del componente, de modo que un componente que lee id = input.required<string>() no necesita inyectar el router para nada.
Formularios reactivos
Los formularios reactivos definen el modelo en TypeScript, lo que lo hace tipado y testeable sin renderizar nada.
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
reference: this.fb.control('', [Validators.required, Validators.maxLength(20)]),
email: this.fb.control('', [Validators.required, Validators.email]),
quantity: this.fb.control(1, [Validators.min(1), Validators.max(50)]),
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.orders.create(this.form.getRawValue()).subscribe(/* … */);
}
Cada control expone value, valid, errors, dirty, touched y un observable valueChanges. Los validadores personalizados son funciones que devuelven un mapa de errores o null, y los validadores asíncronos devuelven un observable: es la forma habitual de comprobar un valor contra el servidor mientras se escribe, con debounce. Muestra un error solo cuando el control esté touched o el formulario se haya enviado; validar en cuanto el usuario teclea el primer carácter es el error de usabilidad más común en los formularios de Angular.
Cómo decide la detección de cambios qué volver a renderizar
Históricamente, Angular dependía de Zone.js, que hace monkey-patching de los temporizadores, los listeners de eventos y XHR para que el framework se entere de cuándo termina cualquier operación asíncrona. Con cada uno de esos eventos recorre el árbol de componentes desde la raíz y vuelve a evaluar todos los bindings de todas las plantillas. Por eso el framework parecía «automático», y por eso también las pantallas grandes se volvían lentas.
ChangeDetectionStrategy.OnPush acota este recorrido: un componente solo se comprueba cuando un input cambia de referencia, se origina un evento dentro de él, cambia un signal que lee o algo lo marca como sucio. Combinado con actualizaciones inmutables, convierte el recorrido de todo el árbol en un camino desde la raíz hasta los componentes que de verdad han cambiado.
Los signals van más allá, porque leer uno en una plantilla registra una dependencia precisa. Eso hace innecesario recorrer el árbol, y es lo que permite la detección de cambios zoneless: con provideZonelessChangeDetection() la aplicación prescinde por completo de la dependencia de Zone.js y programa una comprobación cuando cambia un signal o se dispara un evento. Bundle más pequeño, stack traces más limpias y se acabaron los ciclos extra misteriosos. Requiere que todo el estado que lee la plantilla sea un signal o algo que marque explícitamente el componente como sucio, y por eso el código centrado en signals es la opción recomendada por defecto para aplicaciones nuevas.
RxJS, estructura del proyecto y testing
RxJS no ha desaparecido; su ámbito se ha reducido a aquello en lo que realmente destaca: los flujos a lo largo del tiempo. Aplicar debounce a un buscador con debounceTime, cancelar una petición que ha quedado obsoleta con switchMap, reintentar con backoff, combinar varias fuentes: nada de eso tiene equivalente con signals. El puente idiomático es toSignal(), que convierte un observable en un signal que la plantilla puede leer sin el pipe async y sin cancelar suscripciones a mano. Para el estado de un componente que es simplemente «el valor actual de algo», los signals suponen menos maquinaria y menos margen de error.
La CLI genera y mantiene el proyecto. ng new, ng generate component orders/order-row, ng serve, ng build, ng test y ng update cubren el trabajo diario, y ng update ejecuta schematics que reescriben tu código ante breaking changes; por eso mantenerse cerca de la versión actual sale mucho más barato que saltarse varias. Organiza por funcionalidad y no por tipo: una carpeta orders con sus componentes, su servicio, sus rutas y sus modelos es mejor que árboles paralelos components y services que obligan a que cada cambio toque cuatro directorios.
Para los tests, TestBed configura un inyector en miniatura para poder renderizar un componente con providers falsos, mientras que un servicio con dependencias inyectadas normalmente se puede probar como una clase normal, sin que intervenga el framework. Prueba la lógica basada en signals asignando inputs y leyendo los valores computados; reserva la cobertura end-to-end para los flujos que atraviesan varios componentes.
Ideas clave
- Los componentes standalone son lo predeterminado: sin NgModules,
bootstrapApplicationen la raíz y providers suministrados por funcionesprovide*. - Las plantillas usan control flow con bloques (
@if,@forcon untrackobligatorio,@switchy@defer), que el compilador entiende directamente. - Los signals guardan el estado,
computedlo deriva de forma perezosa yeffectse sincroniza con el mundo exterior; las actualizaciones tienen que ser inmutables para que todo esto funcione. inject()es la forma moderna de acceder a los servicios, los interceptores son funciones normales y los observables deHttpClientson fríos y cancelables.- OnPush y los signals reducen la detección de cambios de un recorrido completo del árbol a una actualización dirigida, y eso es lo que hace posibles las aplicaciones zoneless.
- Reserva RxJS para los flujos basados en el tiempo y conéctalo con
toSignal(); organiza por funcionalidad y deja queng updatete lleve de una versión a otra.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Todavía necesito NgModules?
- No para código nuevo. Los componentes, directivas y pipes son standalone por defecto, las aplicaciones arrancan con bootstrapApplication y los providers se suministran mediante funciones como provideRouter y provideHttpClient. Los NgModules siguen soportados para que las aplicaciones grandes existentes sigan funcionando y puedan migrar de forma gradual, y algunas librerías de terceros todavía los incluyen, pero nada en un proyecto nuevo te obliga a declarar uno.
- ¿Los signals sustituyen a RxJS?
- Se solapan, pero resuelven problemas distintos. Los signals modelan estado síncrono que lee la plantilla, con seguimiento automático de dependencias y sin gestionar suscripciones. RxJS modela flujos de eventos a lo largo del tiempo y te da operadores para hacer debounce, reintentar, cancelar y combinar trabajo asíncrono. El código moderno típico usa signals para el estado de los componentes y RxJS para los pipelines de HTTP y de eventos, y los conecta con toSignal.
- ¿Qué hace realmente la detección de cambios OnPush?
- Por defecto, Angular comprueba todos los componentes del árbol cada vez que termina cualquier operación asíncrona. Con OnPush, un componente solo se comprueba cuando uno de sus inputs cambia de referencia, se dispara un evento dentro de él, cambia un signal que lee o se marca explícitamente como sucio. En una pantalla grande esto elimina la mayor parte del trabajo de cada ciclo. Los componentes basados en signals hacen que el comportamiento sea predecible, y eso es lo que permite las aplicaciones zoneless.
- ¿Formularios reactivos o formularios basados en plantillas?
- Prefiere los formularios reactivos para cualquier cosa que vaya más allá de un buscador de dos campos. El modelo del formulario se define en TypeScript, así que está tipado, se puede probar con tests unitarios sin renderizar y es fácil de componer, y los controles dinámicos o los validadores entre campos no tienen complicación. Los formularios basados en plantillas ponen el modelo en el marcado con ngModel, lo que es más rápido en casos triviales pero se vuelve difícil de probar y de entender a medida que crece la validación.