Ir al contenido

Angular Signals

Reactividad sin magia: qué son los Signals de Angular, cómo funcionan y cuándo usar cada una de sus tres variantes.

Un Signal es una variable reactiva. La diferencia clave frente a una variable normal es esta: cuando su valor cambia, Angular actualiza automáticamente cualquier parte del template que lo use. No tiene que decirle a Angular “oye, actualiza esto”, él lo detecta solo.

Los Signals están disponibles en Angular desde la versión 17.

✗ Variable normal

count = 0;
// Para actualizar el template
// Angular no se entera solo.
// Dependes de zone.js
// o markForCheck().
count++;

✓ Signal

count = signal(0);
// Angular detecta el cambio
// y actualiza el template
// automáticamente.
count.set(count() + 1);

Con estos tres conceptos ya puede escribir componentes reactivos completos.

signal(), Signal base

Es el punto de partida. Crea un valor reactivo que puede cambiar. Para leer: count(). Para escribir: count.set(n) o count.update(v => v+1).

computed(), Signal derivado

Solo lectura. Su valor se calcula a partir de otros Signals. Produce un valor que puede mostrar en el template. Se recalcula automáticamente y está memorizado.

effect(), Efecto secundario

No produce ningún valor. Ejecuta código cada vez que un Signal cambia: guardar en localStorage, llamar una API, sincronizar con librerías externas.

Este es el patrón completo que verá en su primer componente Angular con Signals:

counter.component.ts
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Contador: {{ count() }}</p>
<p>El doble: {{ double() }}</p>
<button (click)="incrementar()">+1</button>
`
})
export class CounterComponent {
// 1. Signal base, valor que puede cambiar
count = signal(0);
// 2. computed, solo lectura, produce un valor para el template
double = computed(() => this.count() * 2);
// 3. effect, ejecuta código cuando count cambia
constructor() {
effect(() => {
console.log('Nuevo valor:', this.count());
});
}
incrementar() {
this.count.update(v => v + 1); // equivalente a .set(count() + 1)
}
}
Acción Sintaxis Qué hace
Leer count() Llama al Signal como función
Escribir count.set(n) Reemplaza el valor completo
Actualizar count.update(v => v+1) Calcula a partir del valor actual
Derivar computed(() => ...) Solo lectura, memorizado

Interactúa con cada concepto para ver cómo se comporta en tiempo real.

count = signal(0);   // crear
count();              // leer → 0
count.set(5);          // escribir
count()=0

Cada vez que llamas a .set(), Angular detecta el cambio y actualiza el template automáticamente. Sin ChangeDetectorRef, sin markForCheck().

Recuerde la nota de más arriba: en el contador simple, quien escribe y quien lee el valor es el mismo componente, así que no había ninguna diferencia frente a una variable normal. Este demo separa esos dos roles en dos componentes que no se conocen entre sí — la única conexión es un servicio:

Clase Qué es Qué hace
CounterService Servicio (@Injectable) Es dueño de la signal count y de las derivadas doubled, isEven, label. Expone los métodos (increment, decrement, reset) para cambiarla.
ControlsComponent Componente Inyecta CounterService con inject() y llama sus métodos. No guarda ni muestra ningún valor.
DisplayComponent Componente Inyecta CounterService con inject() y lee sus signals en el template. No modifica nada.

“Inyectar” un servicio no es crear una instancia propia. inject(CounterService) le pide a Angular la instancia de esa clase — como CounterService está registrado con providedIn: 'root', Angular crea una sola instancia para toda la aplicación y se la entrega a quien la pida. Por eso ControlsComponent y DisplayComponent terminan compartiendo exactamente la misma instancia, con las mismas signals adentro, sin que uno sepa que el otro existe.

counter.service.ts
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
readonly doubled = computed(() => this._count() * 2);
readonly isEven = computed(() => this._count() % 2 === 0);
readonly label = computed(() => `Contador: ${this._count()}`);
increment() { this._count.update(v => v + 1); }
decrement() { this._count.update(v => v - 1); }
reset() { this._count.set(0); }
}
controls.component.ts
@Component({
selector: 'app-controls',
template: `
<button (click)="service.decrement()">− 1</button>
<button (click)="service.increment()">+ 1</button>
<button (click)="service.reset()">reset</button>
`
})
export class ControlsComponent {
service = inject(CounterService);
}
display.component.ts
@Component({
selector: 'app-display',
template: `
<p>count: {{ service.count() }}</p>
<p>doubled: {{ service.doubled() }}</p>
<p>isEven: {{ service.isEven() }}</p>
<p>{{ service.label() }}</p>
`
})
export class DisplayComponent {
service = inject(CounterService);
}

Así se ve en la práctica — pruebe hacer clic a la izquierda y observe la derecha:

ControlsComponentEscribe, nunca muestra

No inyecta ni conoce a DisplayComponent.

DisplayComponentMuestra, nunca escribe
count()0
doubled()0
isEven()true
label()Contador: 0

No inyecta ni conoce a ControlsComponent.

Ningún componente conoce al otro. No hay @Input, no hay @Output, no hay suscripción manual. La única conexión es CounterService: cuandoControlsComponent llama increment(), la signal cambia dentro del servicio, y DisplayComponent se actualiza solo porque lee esa signal en su template.

La pregunta que le resuelve la duda en un segundo: ¿necesita un valor para mostrar en el template? Si sí → computed(). Si lo que necesita es que algo ocurra cuando un Signal cambia → effect().

El siguiente demo usa un carrito de compras. A la izquierda, computed() calcula valores que se muestran en pantalla. A la derecha, effect() ejecuta acciones cada vez que el carrito cambia, sin producir ningún valor visible por sí mismo.

computed()Produce valores para el template
effect()Ejecuta acciones secundarias
// Se dispara cada vez que el carrito cambia
¿Necesitas mostrar algo en el template?
→ usa computed()
total(), hayItems(), precioFormateado()
¿Necesitas que algo ocurra cuando un Signal cambia?
→ usa effect()
guardar en localStorage, llamar API, loggear

La elección entre los tres depende de si el valor es origen, derivado o acción:

Regla de decisión
// ¿El valor cambia por acción del usuario o del sistema?
// → signal()
nombre = signal('');
isLoading = signal(false);
productos = signal<Producto[]>([]);
// ¿El valor se calcula a partir de otros Signals y lo muestra en el template?
// → computed()
total = computed(() => this.productos().reduce((s, p) => s + p.precio, 0));
hayItems = computed(() => this.productos().length > 0);
// ¿Necesita que algo ocurra cuando un Signal cambia?
// → effect()
effect(() => {
localStorage.setItem('nombre', this.nombre());
});
1. ¿Qué pasa cuando cambia el valor de un Signal?
2. ¿Cómo se lee el valor actual de un Signal llamado count?
3. ¿Cuál es la diferencia entre .set() y .update()?
4. ¿Qué produce computed()?
5. ¿Qué diferencia a effect() de computed()?
6. Necesita un valor para mostrar en el template a partir de otros Signals. ¿Cuál usa?
7. CounterService está registrado con providedIn: 'root'. ¿Qué pasa cuando dos componentes distintos hacen inject(CounterService)?
8. En el contador simple, el mismo componente escribe y lee el Signal en el mismo clic. ¿Por qué ahí no se nota diferencia frente a una variable normal?