Angular Signals llegó para quedarse. Después de dos años de RxJS puro y algunos meses conviviendo con ambos modelos en proyectos reales, puedo decirte con claridad cuándo usar cada uno, cuáles son las trampas comunes y por qué Signals no es solo “otra forma de hacer lo mismo”.
El problema que Signals resuelve
Zone.js, el mecanismo que Angular usaba para detectar cambios, funciona interceptando todas las operaciones asíncronas del navegador (setTimeouts, Promises, eventos DOM). Es brillante, pero tiene un costo: cualquier evento en cualquier parte de tu app puede disparar detección de cambios en toda la jerarquía de componentes.
Signals cambia el modelo: en lugar de detectar qué cambió, tú declaras explícitamente qué depende de qué. El resultado es detección de cambios quirúrgica, sin barrido de árbol.
Los tres primitivos que necesitas conocer
import { signal, computed, effect } from '@angular/core';
// signal: estado mutable
const count = signal(0);
count.set(1);
count.update((v) => v + 1);
console.log(count()); // 2 (se lee como función)
// computed: derivación memoizada (solo recalcula si sus dependencias cambian)
const doubled = computed(() => count() * 2);
console.log(doubled()); // 4
// effect: side effect reactivo (cuidado con el abuso)
effect(() => {
console.log(`El contador cambió a: ${count()}`);
});
computed es memoizado automáticamente. Si count() no cambia, doubled() devuelve el valor cacheado sin ejecutar la función. Esto es detección de cambios O(1) por derivación.
Signals en componentes: el patrón correcto
@Component({
selector: 'app-users',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngFor="let user of users()">{{ user.name }}</div>
<p>Total: {{ userCount() }}</p>
`,
})
export class UsersComponent {
private userService = inject(UserService);
// Signal de estado local
readonly searchTerm = signal('');
// Señal derivada del servicio + estado local
readonly users = computed(() =>
this.userService
.allUsers()
.filter((u) =>
u.name.toLowerCase().includes(this.searchTerm().toLowerCase()),
),
);
readonly userCount = computed(() => this.users().length);
updateSearch(term: string) {
this.searchTerm.set(term);
}
}
Nota el ChangeDetectionStrategy.OnPush. Con Signals, el componente solo se re-renderiza cuando un Signal que usa en el template cambia. No hay barrido de árbol. No hay Zone.js involucrado.
Cuándo seguir usando RxJS
Signals no reemplaza RxJS. Los casos donde RxJS sigue siendo la herramienta correcta:
- Streams de eventos: teclas presionadas, clicks con debounce
- Operaciones asíncronas con cancelación: requests que se cancelan al navegar
- Combinación compleja de fuentes:
combineLatest,switchMap,race
La buena noticia: puedes convertir entre ambos mundos fácilmente:
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Observable → Signal
const search$ = fromEvent(input, 'input').pipe(
debounceTime(300),
map((e) => (e.target as HTMLInputElement).value),
);
const searchSignal = toSignal(search$, { initialValue: '' });
// Signal → Observable
const searchObs$ = toObservable(this.searchTerm);
El error más común que veo
Usar effect para sincronizar signals entre sí. Es el anti-patrón más frecuente:
// ❌ MAL: effect para derivar estado
effect(() => {
this.filteredUsers.set(
this.users().filter(u => u.active === this.showActive())
);
});
// ✅ BIEN: computed para derivar estado
readonly filteredUsers = computed(() =>
this.users().filter(u => u.active === this.showActive())
);
effect es para side effects con el mundo exterior (DOM directo, logs, analytics). Para derivar estado, siempre computed.
El veredicto después de 6 meses
Signals simplifica dramáticamente el modelo mental de reactividad en Angular. El código es más legible, más predecible y más performante. Si estás en Angular 16+, es el momento de empezar a migrar.