El panorama del desarrollo web está en constante evolución, y Angular, como uno de los frameworks más robustos y completos, no es la excepción. Con cada nueva versión, el equipo de Angular no solo optimiza el rendimiento y la experiencia de desarrollo, sino que también introduce paradigmas que buscan simplificar la reactividad y la gestión del estado en nuestras aplicaciones. Si bien el modelo de cambio de detección basado en zonas ha sido fundamental para Angular durante años, la llegada de los "signals" marca un antes y un después, ofreciendo una granularidad y una claridad en la reactividad que, en mi opinión, son transformadoras. En este tutorial, nos sumergiremos en una de las implementaciones más significativas de este nuevo paradigma: los "signal inputs". Exploraremos cómo se utilizan, qué ventajas ofrecen y cómo están allanando el camino para una nueva generación de componentes más eficientes y fáciles de mantener. Prepárense para entender cómo esta característica no es solo una adición más, sino una pieza clave en la estrategia a largo plazo de Angular para un futuro más reactivo y sin zonas.
La evolución hacia los "signals" en Angular
El concepto de reactividad es central en el desarrollo de aplicaciones web modernas. Queremos que nuestra interfaz de usuario se actualice automáticamente cuando los datos subyacentes cambian, sin tener que gestionar manualmente esas actualizaciones. Durante mucho tiempo, Angular ha abordado esto con su mecanismo de detección de cambios basado en Zone.js, una herramienta poderosa que intercepta eventos asíncronos para notificar al framework que algo podría haber cambiado y que una actualización de la vista podría ser necesaria. Sin embargo, este enfoque, aunque robusto, a veces puede llevar a detecciones de cambios excesivas o a la necesidad de optimizaciones manuales para evitar problemas de rendimiento. Aquí es donde los "signals" entran en juego, ofreciendo una alternativa más directa y granular para manejar la reactividad.
Contexto y motivación
La motivación principal detrás de la introducción de los "signals" en Angular radica en la búsqueda de una mayor granularidad y eficiencia en la detección de cambios, así como en la simplificación de la gestión del estado. Históricamente, en Angular, las propiedades de los componentes se actualizaban y su detección de cambios dependía del ciclo de vida de la zona. Esto significaba que cualquier operación asíncrona dentro de la zona podía desencadenar un ciclo de detección de cambios completo, afectando a la aplicación en su conjunto. Con los "signals", el objetivo es pasar a un modelo en el que solo las partes de la interfaz de usuario que realmente dependen de un valor de "signal" específico se actualicen cuando ese valor cambie. Esto no solo promete mejoras significativas en el rendimiento al reducir las operaciones innecesarias, sino que también hace que el flujo de datos sea mucho más explícito y fácil de seguir. Es un cambio fundamental que alinea Angular con tendencias modernas en otros frameworks y librerías, como Solid.js o Preact con Signals, que ya han demostrado los beneficios de este enfoque. Personalmente, considero que esta dirección es un gran acierto, ya que aborda algunas de las complejidades inherentes al modelo de detección de cambios actual.
¿Qué son los "signals"? Breve repaso
Antes de sumergirnos en los "signal inputs", es crucial tener una comprensión básica de qué son los "signals" en Angular. Un "signal" es un envoltorio reactivo alrededor de un valor. Puede ser cualquier tipo de dato: un número, una cadena, un objeto, un array. Lo especial de un "signal" es que te permite leer su valor de manera síncrona y, lo que es más importante, notifica a cualquier "interesado" (cualquier código que haya leído su valor) cuando su valor cambia. Esto crea un grafo de dependencias reactivas donde los cambios fluyen de manera predecible y eficiente.
Existen tres tipos principales de "signals":
-
signal(): Crea un "signal" mutable. Puedes actualizar su valor directamente usando el métodoset()oupdate().import { signal } from '@angular/core'; const contador = signal(0); console.log(contador()); // 0 contador.set(1); console.log(contador()); // 1 contador.update(valorActual => valorActual + 1); console.log(contador()); // 2 -
computed(): Crea un "signal" de solo lectura cuyo valor se calcula a partir de otros "signals". Es perezoso, lo que significa que su valor solo se recalcula cuando se lee y cuando las dependencias de "signal" de las que depende cambian.import { signal, computed } from '@angular/core'; const precio = signal(10); const cantidad = signal(2); const total = computed(() => precio() * cantidad()); // Depende de precio y cantidad console.log(total()); // 20 cantidad.set(3); console.log(total()); // 30 (recalculado) -
effect(): Registra una función que se ejecuta cada vez que uno o más "signals" que lee cambian. Los efectos siempre se ejecutan al menos una vez y son útiles para sincronizar el estado de "signals" con el DOM, hacer logging, o interactuar con APIs externas que no son reactivas.import { signal, effect } from '@angular/core'; const nombreUsuario = signal('Juan'); effect(() => { console.log(`El nombre de usuario ha cambiado a: ${nombreUsuario()}`); }); nombreUsuario.set('Pedro'); // Output: El nombre de usuario ha cambiado a: Pedro
Esta infraestructura de "signals" es la base sobre la que se construyen los "signal inputs", llevándola a la interacción entre componentes. Para más detalles sobre la implementación general de los signals, puedes consultar la documentación oficial de Angular sobre Signals.
La novedad: "signal inputs" en Angular
Con las versiones recientes de Angular (especialmente a partir de la v17.1 con model() y la v17.3 con input()), la reactividad basada en "signals" se ha extendido al mecanismo de entrada de datos de los componentes. Los "signal inputs" son una forma moderna y declarativa de definir propiedades de entrada para los componentes, aprovechando directamente el poder de los "signals". Esto no solo simplifica la manera en que los componentes reciben y reaccionan a los datos de sus padres, sino que también elimina la necesidad de ngOnChanges en muchos escenarios, lo cual es una ganancia considerable en simplicidad y claridad de código. Al adoptar signal inputs, estamos dando un paso importante hacia componentes completamente reactivos y, potencialmente, hacia una arquitectura sin Zone.js en el futuro. Es un cambio que, para mí, mejora drásticamente la ergonomía del desarrollador.
Declaración y uso básico
Declarar un "signal input" es sorprendentemente sencillo y se asemeja mucho a la forma en que se declaraban los "signals" regulares. En lugar de usar el decorador @Input(), ahora usamos la función input() o input.required() dentro de la clase del componente.
Aquí hay un ejemplo de cómo definir un componente con un "signal input" simple:
// src/app/componentes/tarjeta-usuario/tarjeta-usuario.component.ts
import { Component, input } from '@angular/core';
import { NgIf, NgClass } from '@angular/common'; // Importaciones para las plantillas
@Component({
selector: 'app-tarjeta-usuario',
standalone: true, // Habilitar componentes independientes
imports: [NgIf, NgClass], // Módulos necesarios
template: `
<div class="tarjeta" [ngClass]="{ 'admin': esAdministrador() }">
<h3>{{ titulo() }}</h3>
<p>Nombre: {{ nombre() }}</p>
<p *ngIf="esAdministrador()">Rol: Administrador</p>
<button (click)="saludar()">Saludar</button>
</div>
`,
styles: `
.tarjeta {
border: 1px solid #ccc;
padding: 15px;
margin: 10px;
border-radius: 8px;
background-color: #f9f9f9;
}
.tarjeta.admin {
border-color: #f44336;
background-color: #ffebee;
}
`
})
export class TarjetaUsuarioComponent {
// Input requerido: Si no se proporciona, Angular lanzará un error.
nombre = input.required<string>();
// Input opcional con un valor por defecto.
titulo = input('Detalles de Usuario');
// Input booleano con valor por defecto
esAdministrador = input(false);
saludar() {
alert(`Hola, soy ${this.nombre()} y mi título es ${this.titulo()}.`);
}
}
Y cómo lo usaríamos desde un componente padre:
// src/app/app.component.ts
import { Component } from '@angular/core';
import { TarjetaUsuarioComponent } from './componentes/tarjeta-usuario/tarjeta-usuario.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [TarjetaUsuarioComponent],
template: `
<app-tarjeta-usuario
[nombre]="'Ana López'"
[titulo]="'Gerente de Proyectos'"
[esAdministrador]="true">
</app-tarjeta-usuario>
<app-tarjeta-usuario
[nombre]="usuarioInvitado"
[titulo]="'Invitado'"
[esAdministrador]="false">
</app-tarjeta-usuario>
`
})
export class AppComponent {
usuarioInvitado = 'Carlos Sánchez';
}
Como pueden observar, los "signal inputs" (nombre, titulo, esAdministrador) se utilizan como funciones para obtener su valor actual (nombre(), titulo(), esAdministrador()), al igual que cualquier otro "signal". Lo interesante es que cualquier cambio en los valores pasados desde el componente padre ([nombre], [titulo], [esAdministrador]) hará que estos "signal inputs" se actualicen automáticamente, y el sistema de detección de cambios de Angular basado en "signals" se encargará de actualizar la vista de manera eficiente.
Propiedades computadas con "signals"
Una de las grandes ventajas de tener los "inputs" como "signals" es la facilidad con la que podemos crear propiedades computadas reactivas basadas en ellos. Esto nos permite derivar nuevos valores o estados a partir de uno o más "signal inputs" de manera declarativa y eficiente. Estos computed() "signals" se recalcularán automáticamente solo cuando sus dependencias de "signal input" cambien, lo que elimina la necesidad de ngOnChanges para estas lógicas.
Veamos un ejemplo donde calculamos si un usuario tiene acceso completo basado en su rol y un permiso específico:
// src/app/componentes/detalles-producto/detalles-producto.component.ts
import { Component, input, computed } from '@angular/core';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-detalles-producto',
standalone: true,
imports: [NgIf],
template: `
<div class="producto">
<h2>{{ nombreProducto() }}</h2>
<p>Precio: {{ precio() | currency:'EUR':'symbol':'1.2-2' }}</p>
<p>Stock disponible: {{ stock() }} unidades</p>
<p *ngIf="esProductoAgotado()" class="agotado">¡Producto agotado!</p>
<p *ngIf="hayPocoStock()" class="poco-stock">Quedan pocas unidades.</p>
</div>
`,
styles: `
.producto {
border: 1px solid #007bff;
padding: 15px;
margin: 10px;
border-radius: 8px;
background-color: #e6f2ff;
}
.agotado {
color: red;
font-weight: bold;
}
.poco-stock {
color: orange;
}
`
})
export class DetallesProductoComponent {
nombreProducto = input.required<string>();
precio = input.required<number>();
stock = input(0); // Valor por defecto para stock
// Propiedad computada para verificar si el producto está agotado
esProductoAgotado = computed(() => this.stock() === 0);
// Propiedad computada para verificar si quedan pocas unidades (ej. menos de 5)
hayPocoStock = computed(() => this.stock() > 0 && this.stock() < 5);
}
Uso desde el componente padre:
// src/app/app.component.ts
import { Component } from '@angular/core';
import { DetallesProductoComponent } from './componentes/detalles-producto/detalles-producto.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [DetallesProductoComponent],
template: `
<app-detalles-producto
[nombreProducto]="'Smartphone X'"
[precio]="799.99"
[stock]="10">
</app-detalles-producto>
<app-detalles-producto
[nombreProducto]="'Auriculares Bluetooth'"
[precio]="129.50"
[stock]="3">
</app-detalles-producto>
<app-detalles-producto
[nombreProducto]="'Smartwatch Z'"
[precio]="249.00"
[stock]="0">
</app-detalles-producto>
`
})
export class AppComponent {
// ...
}
Aquí, esProductoAgotado y hayPocoStock son computed "signals" que reaccionan automáticamente a los cambios en el stock del producto. Si el componente padre actualiza el stock del DetallesProductoComponent, estas propiedades computadas se recalcularán y la interfaz de usuario se actualizará de forma eficiente. Esta es una forma muy limpia y declarativa de manejar lógica derivada de los "inputs". Para comprender mejor el poder de los computed "signals" y su diferencia con los pipes o getters tradicionales, sugiero revisar este artículo sobre mejores prácticas con Angular Signals.
Observadores de efectos con "signals"
Aunque la reactividad en la plantilla es la aplicación más común de los "signals", a veces necesitamos ejecutar efectos secundarios cuando un "signal input" cambia. Esto puede ser útil para:
- Realizar llamadas API.
- Actualizar variables en el
localStorage. - Sincronizar el estado con librerías externas.
- Lanzar eventos de análisis.
Para estos casos, podemos usar la función effect() dentro del componente, que escuchará los cambios en nuestros "signal inputs".
// src/app/componentes/notificacion-alerta/notificacion-alerta.component.ts
import { Component, input, effect } from '@angular/core';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-notificacion-alerta',
standalone: true,
imports: [NgIf],
template: `
<div *ngIf="mostrarAlerta()" class="alerta" [class]="tipoAlerta()">
<p>{{ mensaje() }}</p>
</div>
`,
styles: `
.alerta {
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 10px;
font-weight: bold;
color: white;
}
.alerta.info { background-color: #2196F3; }
.alerta.warning { background-color: #ff9800; }
.alerta.error { background-color: #f44336; }
`
})
export class NotificacionAlertaComponent {
mensaje = input.required<string>();
tipoAlerta = input<'info' | 'warning' | 'error'>('info');
mostrarAlerta = input(true);
constructor() {
// Cuando el mensaje o el tipo de alerta cambien, registramos el cambio
effect(() => {
if (this.mostrarAlerta()) {
console.log(`Nueva alerta mostrada: [${this.tipoAlerta().toUpperCase()}] - ${this.mensaje()}`);
// Aquí podrías, por ejemplo, enviar esta información a un servicio de logging
// o a una API de notificaciones.
} else {
console.log('La alerta ha sido ocultada.');
}
});
}
}
Uso desde el componente padre:
// src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { NotificacionAlertaComponent } from './componentes/notificacion-alerta/notificacion-alerta.component';
import { NgFor } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [NotificacionAlertaComponent, NgFor],
template: `
<app-notificacion-alerta
[mensaje]="'Bienvenido a nuestra aplicación.'"
[tipoAlerta]="'info'">
</app-notificacion-alerta>
<app-notificacion-alerta
[mensaje]="'¡Precaución! El servidor está a punto de quedarse sin espacio.'"
[tipoAlerta]="'warning'">
</app-notificacion-alerta>
<app-notificacion-alerta
[mensaje]="errorMensaje()"
[tipoAlerta]="'error'"
[mostrarAlerta]="mostrarError()">
</app-notificacion-alerta>
<button (click)="toggleError()">{{ mostrarError() ? 'Ocultar' : 'Mostrar' }} Error</button>
<button (click)="cambiarMensajeError()">Cambiar Mensaje Error</button>
`
})
export class AppComponent {
errorMensaje = signal('Ha ocurrido un error inesperado al cargar los datos.');
mostrarError = signal(true);
toggleError() {
this.mostrarError.update(val => !val);
}
cambiarMensajeError() {
this.errorMensaje.set('Fallo de conexión con la base de datos.');
}
}
En este ejemplo, cada vez que mensaje() o tipoAlerta() cambian, el effect en el NotificacionAlertaComponent se disparará y registrará el nuevo estado de la alerta. Esto nos da un control preciso sobre las acciones a realizar en respuesta a cambios en las propiedades de entrada, sin la sobrecarga de ngOnChanges o la necesidad de envolver los "inputs" en Subjects de RxJS. Para un análisis más profundo de las diferencias entre Signals y RxJS, y cuándo usar cada uno, este recurso podría ser de interés: RxJS vs. Angular Signals.
Construyendo componentes completamente reactivos con "signals"
La verdadera potencia de los "signal inputs" se manifiesta cuando los combinamos con el resto del ecosistema de "signals" de Angular para construir componentes cuya reactividad es completamente