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).
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:
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
Cada vez que llamas a .set(), Angular detecta el cambio y actualiza el template automáticamente. Sin ChangeDetectorRef, sin markForCheck().
count = signal(0);
double = computed(() => count() * 2);
label = computed(() => `clicks: ${count()}`);computed() es de solo lectura — no puedes hacer.set() sobre él. Angular lo recalcula solo cuando alguna dependencia cambia (lazy y memorizado).
count = signal(0);
effect(() => {
console.log('Nuevo valor:', count());
});effect() se ejecuta una vez al iniciar el componente y luego cada vez que cambia cualquier Signal que lea. Úsalo para efectos secundarios: logs, llamadas a APIs, sincronizar con librerías externas.
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.
@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); }}@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);}@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:
No inyecta ni conoce a DisplayComponent.
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.
La elección entre los tres depende de si el valor es origen, derivado o acció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());});