Angular Signals: La Revolución de la Reactividad sin RxJS
Angular Signals es el cambio más importante en el modelo de reactividad de Angular desde su creación. Introducido en Angular 16 y estabilizado en Angular 17, Signals representa una forma fundamentalmente diferente de pensar sobre el estado y la reactividad en nuestras aplicaciones.
¿Qué es un Signal?
Un Signal es un envoltorio reactivo alrededor de un valor. Cuando ese valor cambia, cualquier cosa que dependa de él se actualiza automáticamente. Es como una celda de una hoja de cálculo: si cambias una celda, todas las fórmulas que la referencian se recalculan.
import { signal, computed, effect } from '@angular/core';
// Crear un signal con un valor inicial
const count = signal(0);
// Leer el valor (hay que llamarlo como función)
console.log(count()); // 0
// Modificar el valor
count.set(5);
count.update(prev => prev + 1); // 6
¿Por qué Signals y no solo RxJS?
| Característica | RxJS Observables | Signals |
|---|---|---|
| Suscripción manual | Sí (subscribe/unsubscribe) | No (automático) |
| Valor síncrono | No (necesita async) | Sí (lectura directa) |
| Curva aprendizaje | Alta | Baja |
| Flujos complejos | Excelente | Limitado (usar RxJS) |
| Change Detection | Zone.js (dirty checking) | Granular (solo lo necesario) |
Los tres pilares: signal, computed y effect
1. signal() — Estado mutable
Es el contenedor de estado. Puede almacenar cualquier tipo de dato.
// Primitivos
const name = signal('German');
const age = signal(28);
const isActive = signal(true);
// Objetos y arrays
const user = signal<User>({ name: 'German', role: 'admin' });
const items = signal<string[]>(['Angular', 'TypeScript']);
// Actualizar un objeto (siempre con nueva referencia)
user.update(current => ({ ...current, role: 'editor' }));
// Añadir a un array
items.update(current => [...current, 'Signals']);
Importante:
signal.set()ysignal.update()comparan por referencia. Para objetos y arrays, siempre crea una nueva referencia.
2. computed() — Valores derivados
computed crea un Signal de solo lectura que se recalcula automáticamente cuando sus dependencias cambian. Es lazy: solo se recalcula cuando alguien lo lee.
const firstName = signal('German');
const lastName = signal('Cordellat');
// Se recalcula automáticamente cuando firstName o lastName cambian
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "German Cordellat"
firstName.set('Juan');
console.log(fullName()); // "Juan Cordellat"
Ejemplo real: Carrito de compras
@Component({...})
export class CartComponent {
items = signal<CartItem[]>([]);
// Todos estos se recalculan automáticamente
totalItems = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);
subtotal = computed(() =>
this.items().reduce((sum, item) => sum + (item.price * item.quantity), 0)
);
tax = computed(() => this.subtotal() * 0.21);
total = computed(() => this.subtotal() + this.tax());
isEmpty = computed(() => this.items().length === 0);
addItem(product: Product) {
this.items.update(items => {
const existing = items.find(i => i.id === product.id);
if (existing) {
return items.map(i =>
i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
);
}
return [...items, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(items => items.filter(i => i.id !== id));
}
}
En la plantilla:
@if (isEmpty()) {
<p>Tu carrito está vacío</p>
} @else {
@for (item of items(); track item.id) {
<cart-item [item]="item" (remove)="removeItem(item.id)" />
}
<div class="summary">
<p>Artículos: {{ totalItems() }}</p>
<p>Subtotal: {{ subtotal() | currency }}</p>
<p>IVA (21%): {{ tax() | currency }}</p>
<p class="font-bold">Total: {{ total() | currency }}</p>
</div>
}
3. effect() — Efectos secundarios
Los efectos se ejecutan cuando cualquier signal que lean cambia. Son ideales para sincronizar estado con el exterior (localStorage, console, analytics).
export class ThemeComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
// Se ejecuta cada vez que theme() cambia
effect(() => {
document.documentElement.setAttribute('data-theme', this.theme());
localStorage.setItem('preferred-theme', this.theme());
});
}
toggle() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}
Regla de oro: No modifiques otros signals dentro de un
effect(). Los efectos son para comunicarse con el mundo exterior, no para derivar estado (para eso usacomputed).
Model Inputs: Signals bidireccionales
Angular 17.1 introduce model(), que crea un signal bidireccional perfecto para componentes de formulario:
@Component({
selector: 'app-rating',
template: `
@for (star of stars(); track $index) {
<button (click)="value.set($index + 1)">
{{ $index < value() ? '★' : '☆' }}
</button>
}
`
})
export class RatingComponent {
value = model(0); // Input + Output bidireccional
max = input(5); // Input de solo lectura
stars = computed(() => Array(this.max()));
}
En el padre:
<app-rating [(value)]="rating" [max]="10" />
<p>Tu valoración: {{ rating() }}</p>
Signal Store (NgRx SignalStore)
Para aplicaciones más grandes, NgRx ofrece signalStore, una librería de estado basada 100% en Signals:
export const TodoStore = signalStore(
withState<TodoState>({
todos: [],
filter: 'all',
loading: false
}),
withComputed(({ todos, filter }) => ({
filteredTodos: computed(() => {
switch (filter()) {
case 'active': return todos().filter(t => !t.completed);
case 'completed': return todos().filter(t => t.completed);
default: return todos();
}
}),
completedCount: computed(() => todos().filter(t => t.completed).length)
})),
withMethods((store, todoService = inject(TodoService)) => ({
async loadTodos() {
patchState(store, { loading: true });
const todos = await firstValueFrom(todoService.getAll());
patchState(store, { todos, loading: false });
},
toggleTodo(id: string) {
patchState(store, {
todos: store.todos().map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
)
});
}
}))
);
Conclusión
Signals no reemplaza a RxJS, sino que lo complementa. Usa Signals para estado local y sincronía, RxJS para flujos asíncronos complejos, y el paquete rxjs-interop como puente entre ambos mundos. La combinación de los tres es la receta del Angular moderno.