Lección 1 de 412 min
Signals: reactividad de grano fino
signal(), computed() y effect(): valores que avisan cuando cambian y cómo se comparan con los observables.
Piensa en un carrito de la compra: cuando el usuario añade un producto, tienen que actualizarse el contador de la cabecera, el subtotal, los gastos de envío y el botón de «Pagar». Si cada uno de esos sitios lee el dato por su cuenta, tarde o temprano alguno se queda desactualizado.
Los signals de Angular lo resuelven con una idea sencilla: un valor que avisa cuando cambia. Lo que depende de él (un cálculo, una plantilla, un efecto) se actualiza solo, y solo eso. Es lo que se llama reactividad de grano fino, y es la base del Angular moderno.
Las tres piezas: signal, computed y effect
signal(): un valor que avisa
Un contenedor de valor que se lee llamándolo y se cambia con set o update.
- Paquete:
- @angular/core
- Escritura:
- set y update
signal(valorInicial) crea un signal escribible. Lo lees llamándolo como una función, cantidad(), y lo cambias con set(nuevoValor), o con update(anterior => nuevo) cuando el valor nuevo depende del anterior.
Al leer un signal dentro de una plantilla, un computed() o un effect(), Angular registra esa dependencia. Cuando el valor cambia, avisa exactamente a quienes lo leyeron, sin revisar el resto de la aplicación.
Por defecto, un signal compara el valor nuevo con el anterior usando Object.is: si pones el mismo valor, no avisa a nadie.
Úsalo cuando
Para cualquier estado que cambie y que la interfaz deba reflejar: un contador, el elemento seleccionado, un filtro o la lista del carrito.
Ten en cuenta
Si guardas un array o un objeto, no lo modifiques por dentro (lista().push(x)): la referencia no cambia y nadie se entera. Crea uno nuevo: update(lista => [...lista, x]).
import { signal } from "@angular/core";
const cantidad = signal(1);
const productos = signal<string[]>([]);
console.log(cantidad()); // 1: se lee llamándolo
cantidad.set(3); // valor nuevo
cantidad.update((n) => n + 1); // a partir del anterior: 4
productos.update((lista) => [...lista, "Café"]); // array nuevo: avisa
productos().push("Té"); // mismo array: nadie se enteracomputed(): valores derivados
Un signal de solo lectura que se calcula a partir de otros y recuerda su resultado.
computed(() => …) crea un signal cuyo valor se deriva de otros signals. Angular detecta qué signals lees dentro de la función y solo recalcula cuando alguno de ellos cambia.
Es perezoso y memoriza el resultado: no calcula nada hasta que alguien lo lee y, si sus dependencias no han cambiado, devuelve el valor guardado sin repetir el cálculo. Por eso puedes filtrar una lista larga dentro de un computed sin miedo.
Las dependencias son dinámicas: si un if impide leer un signal, ese signal deja de ser dependencia hasta que se vuelva a leer.
Úsalo cuando
Para todo lo que se pueda calcular a partir de otro estado: totales, listas filtradas, textos formateados o si un formulario es válido. Es mejor que guardar ese dato en un segundo signal y sincronizarlo a mano.
Ten en cuenta
Un computed es de solo lectura: no tiene set. Y su función debe ser pura: nada de peticiones HTTP ni de cambiar otros signals dentro.
import { computed, signal } from "@angular/core";
const precio = signal(12.5);
const cantidad = signal(2);
const cupon = signal<string | null>(null);
const subtotal = computed(() => precio() * cantidad());
const total = computed(() => (cupon() === "VERANO" ? subtotal() * 0.9 : subtotal()));
console.log(total()); // 25
cantidad.set(4);
console.log(total()); // 50: se recalcula porque cantidad cambióeffect(): reaccionar con efectos secundarios
Una función que vuelve a ejecutarse cada vez que cambia algún signal que lee.
effect(() => …) ejecuta una función al menos una vez y la repite cada vez que cambia alguno de los signals que lee. Su trabajo es conectar el mundo de los signals con lo que queda fuera de él: guardar en localStorage, escribir un registro, dibujar en un <canvas> o actualizar una librería de gráficos que no conoce Angular.
Los efectos se ejecutan de forma asíncrona, durante la detección de cambios, no en el mismo instante del set. Necesitan un contexto de inyección, así que se crean en el constructor o al declarar un campo, y se destruyen solos junto con el componente.
Úsalo cuando
Para sincronizar el estado con APIs externas: almacenamiento del navegador, registros, analítica o librerías que manipulan el DOM.
Ten en cuenta
No uses effect para copiar un signal en otro: si un valor se deriva de otros, es un computed (o un linkedSignal, si además debe poder editarse). Propagar estado con efectos crea actualizaciones encadenadas difíciles de seguir.
import { Component, effect, signal } from "@angular/core";
@Component({ selector: "app-preferencias", template: "…" })
export class Preferencias {
readonly tema = signal<"claro" | "oscuro">("claro");
constructor() {
effect(() => {
// Se ejecuta al crearse y cada vez que cambia tema()
localStorage.setItem("tema", this.tema());
});
}
}Atención
Leer un signal es llamarlo. Escribir {{ total }} en la plantilla en lugar de {{ total() }} es uno de los errores más comunes al empezar: no muestras el valor y Angular no registra la dependencia. El compilador suele avisarte, pero conviene tenerlo presente.
Signals en los componentes
En las plantillas, los signals se leen igual que en la clase: {{ total() }}. Angular sabe qué componente lee cada signal y, cuando cambia, actualiza solo ese componente. Es lo que permite al Angular reciente funcionar sin Zone.js; si quieres repasar por qué importa, vuelve a la lección de detección de cambios. Las entradas de los componentes también son signals.
input(): entradas como signals
Las entradas del componente llegan como signals de solo lectura.
En el Angular moderno, una entrada se declara con input() en lugar del decorador @Input(). El resultado es un signal de solo lectura: lo lees con nombre() en la clase o en la plantilla, y puedes derivar valores de él con computed.
input(valorPorDefecto) crea una entrada opcional, e input.required<T>(), una obligatoria: si el padre no la pasa, el compilador da error. Con la opción transform conviertes el valor al recibirlo; por ejemplo, con booleanAttribute basta con escribir <app-avatar redondo>.
Úsalo cuando
Para todas las entradas nuevas: se combinan de forma natural con computed y con la detección de cambios basada en signals.
Ten en cuenta
El componente no puede escribir en sus propias entradas: un input() no tiene set. Si necesitas un valor que nace de la entrada pero que el componente puede editar, usa linkedSignal; si además el padre debe enterarse, model().
import { Component, booleanAttribute, computed, input } from "@angular/core";
@Component({
selector: "app-avatar",
template: `<img [src]="url()" [alt]="nombre()" [class.redondo]="redondo()" />
<span>{{ iniciales() }}</span>`,
})
export class Avatar {
readonly nombre = input.required<string>();
readonly url = input("/img/anonimo.png");
readonly redondo = input(false, { transform: booleanAttribute });
readonly iniciales = computed(() =>
this.nombre().split(" ").map((parte) => parte[0]).join(""),
);
}
// Uso: <app-avatar nombre="Ana Núñez" redondo />model(): enlace bidireccional
Una entrada que el propio componente puede cambiar y que avisa al padre.
model() crea un signal escribible que funciona como entrada y salida a la vez. El componente lo lee y lo cambia con set o update, y Angular emite automáticamente el evento de cambio (valorChange) para que el padre se entere.
El padre lo enlaza en ambos sentidos con la sintaxis de «banana en una caja», [(valor)]. Si le pasa un signal escribible, sin paréntesis, padre e hijo comparten el valor sin código adicional.
Úsalo cuando
Para controles reutilizables cuyo valor pertenece al padre: un selector de cantidad, un interruptor o una valoración con estrellas.
Ten en cuenta
No lo uses para todo: si el padre solo pasa datos y el hijo solo avisa de acciones, una entrada con input() y un evento con output() dejan más claro quién manda.
import { Component, model } from "@angular/core";
@Component({
selector: "app-cantidad",
template: `
<button (click)="valor.update((n) => n - 1)" [disabled]="valor() <= 1">−</button>
<span>{{ valor() }}</span>
<button (click)="valor.update((n) => n + 1)">+</button>
`,
})
export class SelectorCantidad {
readonly valor = model(1);
}
// En el padre, con readonly cantidad = signal(2):
// <app-cantidad [(valor)]="cantidad" />APIs recientes y puentes con RxJS
Las tres piezas básicas cubren mucho, pero dejan dos huecos: el estado derivado que también se puede editar y los datos que llegan de forma asíncrona. Angular ha añadido APIs para ambos en sus versiones recientes; algunas aún evolucionan, así que comprueba su estado en la documentación de tu versión.
linkedSignal(): estado editable que se reinicia
Un signal escribible que vuelve a calcularse cuando cambia su origen.
A veces un valor se deriva de otro, pero el usuario también puede cambiarlo. Por ejemplo, la opción de envío empieza siendo la primera de la lista, el usuario puede elegir otra y, si la lista cambia, la selección debe volver a una opción válida. Un computed no se puede editar y un signal no se reinicia solo.
linkedSignal(() => …) cubre ese hueco: se calcula como un computed, admite set y update como un signal y vuelve a calcularse cuando cambia lo que lee. Con la forma { source, computation } recibes también el valor anterior, por si quieres conservar la selección cuando sigue siendo válida.
Úsalo cuando
Para selecciones que dependen de una lista, valores de formulario que parten de una entrada o cualquier «valor por defecto que el usuario puede cambiar».
Ten en cuenta
Es una API reciente: en proyectos con versiones antiguas de Angular puede no existir o estar en vista previa. Compruébalo en angular.dev antes de usarla.
import { linkedSignal, signal } from "@angular/core";
const opcionesEnvio = signal(["Estándar", "Urgente"]);
// Empieza en la primera opción, pero el usuario puede cambiarla
const envio = linkedSignal(() => opcionesEnvio()[0]);
envio.set("Urgente"); // el usuario elige
opcionesEnvio.set(["Recogida en tienda"]); // la lista cambia…
console.log(envio()); // …y vuelve a "Recogida en tienda"resource(): datos asíncronos como signals
Carga datos cuando cambian sus parámetros y expone valor, estado y error como signals.
resource() conecta los signals con operaciones asíncronas. Le das una función params que lee signals y un loader que devuelve una promesa; cada vez que los parámetros cambian, vuelve a cargar y cancela la carga anterior mediante un AbortSignal.
El resultado expone signals para todo lo que necesita la plantilla: value(), isLoading(), error() y hasValue(), además de reload() para repetir la carga. Para peticiones HTTP existe también httpResource, que usa HttpClient por debajo.
Úsalo cuando
Para leer datos que dependen del estado del componente, como la ficha de un usuario según su id, sin programar a mano la carga, el error y la cancelación.
Ten en cuenta
Es de las APIs más nuevas y su forma ha cambiado entre versiones (el parámetro params se llamaba antes request), así que revisa la documentación de la tuya. Está pensada para leer datos; para guardar, sigue usando llamadas normales.
import { Component, input, resource } from "@angular/core";
@Component({
selector: "app-ficha-usuario",
template: `
@if (usuario.isLoading()) { <p>Cargando…</p> }
@else if (usuario.hasValue()) { <h2>{{ usuario.value().nombre }}</h2> }
`,
})
export class FichaUsuario {
readonly id = input.required<number>();
readonly usuario = resource({
params: () => ({ id: this.id() }),
loader: async ({ params, abortSignal }) => {
const respuesta = await fetch(`/api/usuarios/${params.id}`, { signal: abortSignal });
return (await respuesta.json()) as Usuario;
},
});
}Signals frente a observables: toSignal y toObservable
Signals para el estado; observables para eventos en el tiempo. Y puentes entre ambos.
Un signal siempre tiene un valor y se lee de forma síncrona: es ideal para el estado que muestra la interfaz. Un observable de RxJS es un flujo de eventos en el tiempo: puede no haber emitido nada aún, emitir muchas veces, terminar o fallar, y tiene operadores para esperar, cancelar o combinar, como verás en la siguiente lección.
El paquete @angular/core/rxjs-interop trae los puentes: toSignal(obs$) se suscribe a un observable y expone su último valor como signal, y se desuscribe solo cuando se destruye el componente; toObservable(signal) convierte un signal en observable para encadenarle operadores como debounceTime o switchMap.
Úsalo cuando
Signals para el estado y la plantilla; RxJS cuando el problema es de tiempo: un buscador que espera a que dejes de escribir, cancelar peticiones, reintentar o combinar varias fuentes. Convierte en la frontera.
Ten en cuenta
Mientras el observable no ha emitido, toSignal vale undefined, salvo que le pases initialValue. Y toObservable emite de forma asíncrona y solo el último valor: si cambias el signal tres veces seguidas, puede emitir una sola vez.
import { inject, signal } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounceTime, switchMap } from "rxjs";
export class Buscador {
private readonly api = inject(ProductosApi);
readonly texto = signal("");
// signal → observable → operadores de RxJS → signal otra vez
readonly resultados = toSignal(
toObservable(this.texto).pipe(
debounceTime(300),
switchMap((texto) => this.api.buscar(texto)),
),
{ initialValue: [] },
);
}Interactivo
Signals frente a observables
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Signal | Observable (RxJS) |
|---|---|---|
| Valor actual | Siempre tiene uno | No necesariamente: puede no haber emitido |
| Cómo se lee | Llamándolo: total() | Suscribiéndote, con el pipe async o con toSignal |
| Suscripción | Automática: Angular sabe quién lo lee | Manual: hay que cancelarla al terminar |
| Tiempo y cancelación | No: solo guarda el valor actual | Sí: debounceTime, switchMap, retry… |
| Ideal para | Estado de la interfaz y valores derivados | Eventos, peticiones y flujos en el tiempo |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
El carrito guarda la lista de productos y la cabecera debe mostrar el importe total, siempre actualizado.
Para recordar
- Un signal es un valor que avisa cuando cambia: se lee llamándolo y se cambia con
setoupdate, creando objetos nuevos en vez de modificarlos. - Lo que se deriva de otro estado es un
computed;effectqueda para efectos hacia fuera, comolocalStorageo librerías externas. - En los componentes,
input()recibe datos de solo lectura ymodel()comparte con el padre un valor editable. linkedSignalyresourceson APIs recientes para el estado derivado editable y los datos asíncronos: revisa su estado en tu versión.- Signals para el estado y RxJS para los eventos en el tiempo;
toSignalytoObservablecruzan la frontera.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.Tienes
const lista = signal(["a"])y haceslista().push("b"). ¿Qué ocurre?2.¿Qué diferencia a
computed()de unsignal()normal?3.Un compañero escribe
effect(() => this.total.set(this.precio() * this.cantidad())). ¿Qué le recomiendas?4.¿Cuándo tiene sentido
model()en lugar deinput()?5.Conviertes una petición con
toSignal(this.api.productos())y la plantilla falla al principio porque el valor esundefined. ¿Por qué?