Módulo 05: Señales
Objetivos del módulo
- Entender qué problema resuelven las señales y por qué Angular ha reconstruido su reactividad sobre ellas
- Crear estado con
signaly modificarlo consetyupdate - Exponer estado de solo lectura con
asReadonlyy comprender qué protege y qué no - Derivar valores con
computedy aprovechar que es perezoso, que memoriza y que sus dependencias son dinámicas - Razonar sobre el grafo reactivo y sobre el ciclo de lectura: se empuja el aviso y se tira del valor
- Escribir una versión mínima de las señales para ver qué ocurre por dentro
- Dominar la igualdad (
Object.is, opciónequal) y actualizar objetos y listas sin mutarlos - Usar
effectsolo para lo que le corresponde, con limpieza, destrucción y contexto de inyección - Romper dependencias con
untracked - Crear estado editable que depende de otro con
linkedSignal, incluida su opciónset(Angular 22.1) - Retardar una señal con
debounced(Angular 22.0, experimental) - Diagnosticar los errores habituales: NG0600, NG0203, NG0602, NG8109, ciclos y bucles de efectos
1. Qué es una señal y qué problema resuelve
Ya conoces la idea básica de una aplicación web: hay datos, hay una pantalla que los muestra, y cuando los datos cambian la pantalla tiene que cambiar con ellos. La pregunta difícil es esta: ¿cómo sabe el framework qué parte de la pantalla depende de qué dato?
Si no lo sabe, solo le queda comprobarlo todo. Durante años, Angular funcionó así: la biblioteca Zone.js avisaba de que «ha pasado algo» (un clic, una respuesta HTTP, un temporizador), y Angular recorría los componentes volviendo a evaluar las expresiones de sus plantillas para buscar diferencias. Funciona, pero hace mucho trabajo inútil, porque un clic en un botón suele afectar a una parte mínima de la página.
Una señal (signal) resuelve el problema al revés: el dato sabe quién lo usa. Es un contenedor de un valor que registra quién lo lee y avisa a esos lectores cuando cambia.
📘 Concepto: Una señal es un valor envuelto en una función. Para leerlo, llamas a la función. Para cambiarlo, usas un método. Quien lee una señal mientras se está ejecutando un cálculo reactivo queda apuntado como dependiente de ella, sin que tengas que declarar nada.
La mejor comparación es una hoja de cálculo. Si la celda C1 contiene =A1*B1, no tienes que decirle a nadie que C1 depende de A1: la fórmula ya lo dice. Al cambiar A1, se recalcula C1, y las celdas que no dependen de A1 ni se tocan.
| Hoja de cálculo | Señales de Angular |
|---|---|
| Celda con un número que escribes tú | signal |
| Celda con una fórmula | computed |
| Macro que se lanza cuando cambia una celda | effect |
| Celda con fórmula que además puedes sobrescribir a mano hasta el siguiente recálculo | linkedSignal |
Un primer componente
Crea src/app/contador.ts. Es la misma idea que viste en el módulo 03, con un valor derivado añadido.
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-contador',
template: `
<p class="valor">Pulsaciones: <strong>{{ cuenta() }}</strong></p>
<p>El doble es {{ doble() }}</p>
<button type="button" (click)="sumar()">Sumar 1</button>
<button type="button" (click)="reiniciar()">Reiniciar</button>
`,
})
export class Contador {
protected readonly cuenta = signal(0);
protected readonly doble = computed(() => this.cuenta() * 2);
protected sumar(): void {
this.cuenta.update((actual) => actual + 1);
}
protected reiniciar(): void {
this.cuenta.set(0);
}
}
Para verlo, añade Contador a los imports de App y escribe <app-contador /> en app.html. Tras pulsar tres veces:

Figura 1. El componente solo declara datos y su relación; Angular actualiza los dos párrafos.
Fíjate en lo que no aparece en el código: ninguna llamada para refrescar la pantalla, ninguna suscripción y ninguna cancelación. La plantilla lee cuenta() y doble(), y esa lectura basta para que Angular sepa qué debe actualizar.
⚠️ Importante: En la plantilla los paréntesis son obligatorios:
{{ cuenta() }}. Una señal es una función, y sin paréntesis estarías intentando mostrar la función, no su valor. La sección 11 explica qué aviso da el compilador.
Probar señales sin componentes
signal y computed no necesitan componentes: son funciones normales que puedes usar en cualquier fichero TypeScript. Eso nos viene muy bien para estudiarlas. En la raíz de un proyecto Angular, crea pruebas.ts y ejecútalo con Node 24, que entiende TypeScript directamente:
node pruebas.ts
Node puede mostrar un aviso sobre el tipo de módulo: es inofensivo. Todos los bloques de código con console.log de las próximas secciones se pueden pegar en ese fichero.
2. signal: estado que se puede escribir
import { isSignal, isWritableSignal, signal } from '@angular/core';
const salario = signal(2000); // WritableSignal<number>
console.log(salario()); // leer: se llama como una función
salario.set(2100); // sustituir el valor
salario.update((actual) => actual + 100); // calcular a partir del actual
console.log(salario());
console.log(typeof salario, isSignal(salario), isWritableSignal(salario));
2000
2200
function true true
| Método | Para qué | Ejemplo |
|---|---|---|
() | Leer el valor actual | salario() |
set(valor) | Sustituir el valor, sin mirar el anterior | filtro.set('') |
update(fn) | Calcular el nuevo valor a partir del anterior | cuenta.update((c) => c + 1) |
asReadonly() | Obtener una vista sin set ni update | valor = this.#valor.asReadonly() |
El tipo se infiere del valor inicial. Cuando no basta, indícalo tú, sobre todo si el valor puede ser null o una unión:
const seleccionado = signal<Empleado | null>(null);
const orden = signal<'nombre' | 'salario'>('nombre');
Sin el genérico, signal(null) sería WritableSignal<null> y nunca podrías guardar un empleado en ella.
Por qué readonly en el campo
Verás siempre protected readonly cuenta = signal(0). El readonly no impide cambiar el valor de la señal: impide sustituir la propia señal por otra. Es importante, porque las plantillas y los cálculos que ya la leen seguirían apuntando a la señal antigua y dejarían de enterarse de los cambios.
Estado privado, lectura pública
El patrón más útil de signal es separar quién puede escribir de quién puede leer. Una clase guarda la señal en privado y publica una vista de solo lectura:
export class Marcador {
readonly #puntos = signal(0);
readonly puntos = this.#puntos.asReadonly();
anotar(valor: number): void {
this.#puntos.update((p) => p + valor);
}
}
Así, todos los cambios pasan por anotar, y hay un solo sitio donde poner reglas. En el módulo 08 convertirás este tipo de clases en servicios compartidos, y en el módulo 19 las usarás para gestionar el estado de una aplicación.
asReadonly protege la señal, no el contenido. Si el valor es un objeto o una lista, se puede mutar igualmente:
const interno = signal(1);
const publico = interno.asReadonly();
console.log(publico(), 'set' in publico, isWritableSignal(publico));
const lista = signal([1]).asReadonly();
lista().push(2); // compila y funciona: la lista no está congelada
console.log(lista());
1 false false
[ 1, 2 ]
💡 Consejo: Declara las listas como
signal<readonly Empleado[]>([]). El tiporeadonlyhace que TypeScript rechacepush,sorty compañía, y te obliga a crear copias. Es la protección más barata que existe, y la sección 6 explica por qué la necesitas.
3. computed: valores derivados
Un valor derivado es cualquier dato que se puede calcular a partir de otros. computed crea una señal de solo lectura cuyo valor sale de una función:
const salario = signal(2000);
const pagas = signal(14);
const anual = computed(() => salario() * pagas());
Nadie le dice a anual de qué depende: lo descubre al ejecutarse, viendo qué señales se leen dentro de la función. Tiene tres propiedades que conviene ver funcionando.
Es perezoso y memoriza
const salario = signal(2200);
const pagas = signal(14);
const anual = computed(() => {
console.log(' calculando anual');
return salario() * pagas();
});
console.log('Creada, aún sin leer');
console.log(anual());
console.log(anual());
salario.set(3000);
console.log('Cambiado el salario, aún sin leer');
console.log(anual());
Creada, aún sin leer
calculando anual
30800
30800
Cambiado el salario, aún sin leer
calculando anual
42000
Hay tres detalles en esta salida:
- Perezoso (lazy): crear el
computedno ejecuta la función. Se ejecuta la primera vez que alguien lo lee. - Memorizado (memoized): la segunda lectura devuelve el valor guardado, sin calcular. Por eso puedes llamar a
anual()diez veces en una plantilla sin preocuparte. - Solo se recalcula cuando hace falta:
salario.set(3000)no calcula nada. Solo marca el valor como obsoleto, y el cálculo espera a la siguiente lectura.
Esto te permite hacer en un computed cosas relativamente caras, como filtrar y ordenar una lista, sin el problema de un método llamado desde la plantilla, que se ejecutaría en cada actualización de la vista.
Las dependencias son dinámicas
Las dependencias se calculan de nuevo en cada ejecución, y solo cuentan las señales que realmente se han leído esa vez:
const mostrarSalario = signal(false);
const importe = signal(1000);
const etiqueta = computed(() => {
console.log(' calculando etiqueta');
return mostrarSalario() ? `${importe()} euros` : 'Confidencial';
});
console.log(etiqueta());
importe.set(2000); // etiqueta no leyó importe la última vez
console.log(etiqueta());
mostrarSalario.set(true);
console.log(etiqueta());
importe.set(3000); // ahora sí depende de importe
console.log(etiqueta());
calculando etiqueta
Confidencial
Confidencial
calculando etiqueta
2000 euros
calculando etiqueta
3000 euros
Mientras mostrarSalario es false, el operador ternario no llega a leer importe, así que cambiarlo no provoca ningún cálculo. Es correcto: ese cambio no podría alterar el resultado.
Esto tiene una consecuencia práctica. Si guardas una lectura en una constante fuera de la función del computed, o si la haces solo en una rama, la dependencia no se registra donde crees. La regla es sencilla: lee las señales dentro de la función, en el momento en que necesitas el valor.
Un caso real: panel de empleados
Vamos a construir una tabla con búsqueda, filtro por departamento y ordenación. Primero, src/app/datos.ts. Son datos reales de la base de datos EMPRESA del curso, escritos a mano porque aún no hemos visto HTTP (lo harás en el módulo 12):
export interface Empleado {
readonly empId: number;
readonly nombre: string;
readonly apellidos: string;
readonly oficio: string;
readonly salario: number;
readonly deptId: number;
}
export interface Departamento {
readonly deptId: number;
readonly nombre: string;
}
export const DEPARTAMENTOS: readonly Departamento[] = [
{ deptId: 10, nombre: 'DIRECCIÓN' },
{ deptId: 20, nombre: 'VENTAS' },
{ deptId: 30, nombre: 'INFORMÁTICA' },
];
export const EMPLEADOS: readonly Empleado[] = [
{ empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10 },
{ empId: 101, nombre: 'Luis', apellidos: 'Martínez Ruiz', oficio: 'DIRECTOR', salario: 4800, deptId: 20 },
{ empId: 102, nombre: 'Marta', apellidos: 'Sánchez Gil', oficio: 'DIRECTOR', salario: 5000, deptId: 30 },
{ empId: 110, nombre: 'Carmen', apellidos: 'Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200, deptId: 20 },
{ empId: 111, nombre: 'David', apellidos: 'Torres Romero', oficio: 'VENDEDOR', salario: 2100, deptId: 20 },
{ empId: 112, nombre: 'Elena', apellidos: 'Navarro Castro', oficio: 'VENDEDOR', salario: 2300, deptId: 20 },
{ empId: 113, nombre: 'Sergio', apellidos: 'Jiménez Vidal', oficio: 'VENDEDOR', salario: 1900, deptId: 20 },
{ empId: 114, nombre: 'Lucía', apellidos: 'Romero Serrano', oficio: 'ADMINISTRATIVO', salario: 1700, deptId: 20 },
{ empId: 120, nombre: 'Pablo', apellidos: 'Moreno Herrera', oficio: 'ANALISTA', salario: 3500, deptId: 30 },
];
Ahora el componente. Antes de leerlo, fíjate en cómo se reparte: hay tres señales de estado, las únicas cosas que el usuario puede cambiar, y todo lo demás es derivado.
import { Component, computed, signal } from '@angular/core';
import { DEPARTAMENTOS, EMPLEADOS } from './datos';
type CampoOrden = 'nombre' | 'salario';
@Component({
selector: 'app-panel-empleados',
template: `
<h2>Empleados</h2>
<div class="filtros">
<label>
Buscar
<input
type="search"
[value]="texto()"
(input)="texto.set($any($event.target).value)"
/>
</label>
<label>
Departamento
<select (change)="deptId.set(+$any($event.target).value)">
<option value="0">Todos</option>
@for (d of departamentos; track d.deptId) {
<option [value]="d.deptId">{{ d.nombre }}</option>
}
</select>
</label>
<button type="button" (click)="cambiarOrden()">
Ordenar por {{ orden() === 'nombre' ? 'salario' : 'nombre' }}
</button>
</div>
<table>
<thead>
<tr><th>Nombre</th><th>Oficio</th><th class="num">Salario</th></tr>
</thead>
<tbody>
@for (e of visibles(); track e.empId) {
<tr>
<td>{{ e.nombre }} {{ e.apellidos }}</td>
<td>{{ e.oficio }}</td>
<td class="num">{{ e.salario }} €</td>
</tr>
} @empty {
<tr><td colspan="3">Ningún empleado coincide con el filtro</td></tr>
}
</tbody>
</table>
<p class="resumen">
{{ resumen().cantidad }} empleados · total {{ resumen().total }} € ·
media {{ resumen().media }} €
</p>
`,
styles: `
:host { display: block; font-family: 'Segoe UI', Arial, sans-serif; color: #37474f; }
h2 { color: #1a237e; margin-top: 0; }
.filtros { display: flex; gap: 1rem; align-items: end; margin-bottom: 1rem; }
label { display: grid; gap: .25rem; font-size: .9rem; }
table { border-collapse: collapse; width: 100%; }
th { background: #e8eaf6; text-align: left; }
th, td { padding: .35rem .6rem; border-bottom: 1px solid #c5cae9; }
.num { text-align: right; }
.resumen { font-weight: 600; }
`,
})
export class PanelEmpleados {
protected readonly departamentos = DEPARTAMENTOS;
// Estado: lo que el usuario puede cambiar
protected readonly texto = signal('');
protected readonly deptId = signal(0);
protected readonly orden = signal<CampoOrden>('nombre');
// Derivados: todo lo que se calcula a partir del estado
protected readonly filtrados = computed(() => {
const texto = this.texto().trim().toLowerCase();
const deptId = this.deptId();
return EMPLEADOS.filter(
(e) =>
(deptId === 0 || e.deptId === deptId) &&
`${e.nombre} ${e.apellidos}`.toLowerCase().includes(texto),
);
});
protected readonly visibles = computed(() => {
const campo = this.orden();
return [...this.filtrados()].sort((a, b) =>
campo === 'nombre'
? a.nombre.localeCompare(b.nombre, 'es')
: b.salario - a.salario,
);
});
protected readonly resumen = computed(() => {
const lista = this.filtrados();
const total = lista.reduce((suma, e) => suma + e.salario, 0);
return {
cantidad: lista.length,
total,
media: lista.length === 0 ? 0 : Math.round(total / lista.length),
};
});
protected cambiarOrden(): void {
this.orden.update((o) => (o === 'nombre' ? 'salario' : 'nombre'));
}
}
Dos apuntes sobre el código:
$any(...)es una función especial de las plantillas que desactiva la comprobación de tipos para esa expresión. La usamos porque$event.targetes de tipoEventTarget, que no tienevalue. Es un atajo para ejemplos pequeños: los formularios bien hechos llegan en los módulos 16, 17 y 18.visiblescopia la lista con[...this.filtrados()]antes de ordenar.sortordena sobre la propia lista, y sin la copia estarías alterando el valor guardado enfiltrados.

Figura 2. El panel recién abierto: sin filtros y ordenado por nombre.

Figura 3. El mismo panel con filtro de texto, filtro de departamento y orden por salario.
⚠️ Importante:
computedsolo detecta señales.EMPLEADOSes una constante normal: si cambiase, el panel no se enteraría. Por eso conviene que todo dato que pueda cambiar esté dentro de una señal.
4. El grafo reactivo y el ciclo de lectura
Las señales y sus lectores forman un grafo: una red de nodos unidos por flechas que dicen quién depende de quién. Los nodos que producen valores son productores (producers), y los que leen otros nodos son consumidores (consumers). Un computed es las dos cosas a la vez. Una plantilla y un effect son solo consumidores.
Figura 4. El grafo reactivo del panel de empleados.
Este grafo no lo declara nadie. Angular lo construye mientras se ejecuta tu código, y lo hace con un truco muy simple: una variable global que guarda quién está calculando ahora. Cuando se lee una señal, esta mira esa variable y, si hay alguien, lo apunta como consumidor. Por eso basta con llamar a cuenta(): la propia lectura crea la flecha.
Empujar el aviso, tirar del valor
Cuando se escribe en una señal, ¿qué debería pasar? Lo intuitivo es recalcular todo lo que depende de ella. Pero sería un derroche: si haces tres escrituras seguidas, se calcularía tres veces, y quizá nadie llegue a leer el resultado.
Las señales dividen el trabajo en dos fases, lo que se conoce como push-pull:
- Empuje (push). Al escribir, la señal sube su número de versión y avisa a sus consumidores, y estos a los suyos. El aviso solo dice «algo ha cambiado, tu valor puede estar obsoleto». No se calcula nada.
- Tirón (pull). Cuando alguien lee un
computedmarcado como obsoleto, este pregunta a sus fuentes si su versión ha cambiado desde la última vez. Si alguna ha cambiado, recalcula. Si ninguna, conserva su valor.
Figura 5. La escritura solo propaga un aviso. El cálculo ocurre al leer.
Las señales por dentro: una versión mínima
La mejor forma de entenderlo es escribirlo. Este programa implementa las tres piezas con la misma estrategia que Angular, sin depender de él. Guárdalo como mini-senales.ts y ejecútalo con node mini-senales.ts. La implementación real tiene más detalles, pero la idea es esta.
// Una versión mínima de las señales, sin Angular, para ver la idea
interface Consumidor {
fuentes: Map<Productor, number>; // de qué dependo y qué versión vi
marcarSucio(): void;
}
let activo: Consumidor | null = null; // quién está calculando ahora mismo
class Productor {
version = 0;
consumidores = new Set<Consumidor>();
registrarLectura(): void {
if (activo) {
activo.fuentes.set(this, this.version);
this.consumidores.add(activo);
}
}
refrescar(): void {} // una señal normal siempre está al día
}
class Senal<T> extends Productor {
private valor: T;
constructor(inicial: T) {
super();
this.valor = inicial;
}
leer(): T {
this.registrarLectura();
return this.valor;
}
escribir(nuevo: T): void {
if (Object.is(this.valor, nuevo)) return; // igual que antes: nadie se entera
this.valor = nuevo;
this.version++;
for (const c of this.consumidores) c.marcarSucio(); // empuje: solo avisa
}
}
function cambioAlgunaFuente(c: Consumidor): boolean {
for (const [fuente, vista] of c.fuentes) {
fuente.refrescar();
if (fuente.version !== vista) return true;
}
return false;
}
function ejecutarRastreando<T>(c: Consumidor, calculo: () => T): T {
for (const fuente of c.fuentes.keys()) fuente.consumidores.delete(c);
c.fuentes.clear(); // las dependencias se descubren de nuevo en cada ejecución
const anterior = activo;
activo = c;
try {
return calculo();
} finally {
activo = anterior;
}
}
class Calculada<T> extends Productor implements Consumidor {
fuentes = new Map<Productor, number>();
private sucio = true;
private valor!: T;
private calculado = false;
private readonly calculo: () => T;
constructor(calculo: () => T) {
super();
this.calculo = calculo;
}
marcarSucio(): void {
if (this.sucio) return;
this.sucio = true;
for (const c of this.consumidores) c.marcarSucio();
}
override refrescar(): void {
if (!this.sucio) return;
this.sucio = false;
if (this.calculado && !cambioAlgunaFuente(this)) return; // tirón: nada cambió
const nuevo = ejecutarRastreando(this, this.calculo);
if (!this.calculado || !Object.is(this.valor, nuevo)) {
this.valor = nuevo;
this.version++;
}
this.calculado = true;
}
leer(): T {
this.refrescar();
this.registrarLectura();
return this.valor;
}
}
class Efecto implements Consumidor {
fuentes = new Map<Productor, number>();
private programado = false;
private readonly accion: () => void;
constructor(accion: () => void) {
this.accion = accion;
this.ejecutar();
}
marcarSucio(): void {
if (this.programado) return;
this.programado = true; // varias escrituras seguidas: una sola ejecución
queueMicrotask(() => {
this.programado = false;
if (cambioAlgunaFuente(this)) this.ejecutar();
});
}
private ejecutar(): void {
ejecutarRastreando(this, this.accion);
}
}
// ---- Uso ----
const salario = new Senal(2000);
const paga = new Senal(14);
const anual = new Calculada(() => {
console.log(' calculo anual');
return salario.leer() * paga.leer();
});
const tramo = new Calculada(() => {
console.log(' calculo tramo');
return anual.leer() > 30000 ? 'alto' : 'bajo';
});
new Efecto(() => console.log('Efecto: tramo =', tramo.leer()));
console.log('Dos escrituras seguidas');
salario.escribir(2100);
salario.escribir(2200);
await Promise.resolve();
await Promise.resolve();
console.log('Cambia anual, pero no tramo');
paga.escribir(15);
await Promise.resolve();
await Promise.resolve();
console.log('anual =', anual.leer());
calculo tramo
calculo anual
Efecto: tramo = bajo
Dos escrituras seguidas
calculo anual
calculo tramo
Efecto: tramo = alto
Cambia anual, pero no tramo
calculo anual
calculo tramo
anual = 33000
Lee la salida con el código delante, porque enseña todo lo importante:
- Descubrimiento de dependencias.
ejecutarRastreandodeja el consumidor enactivo, y cadaleer()lo apunta.activose restaura al terminar, por eso uncomputeddentro de otro funciona. - Lotes. Tras dos escrituras seguidas, el cálculo aparece una sola vez. Las escrituras solo marcan, y el efecto espera a una microtarea, cuando las dos ya han terminado.
- Parada por igualdad. En el último bloque,
anualcambia de valor ytramose recalcula, pero sigue valiendo'alto'. Como su versión no sube,cambioAlgunaFuentedevuelvefalsey el efecto no se ejecuta. Fíjate en que no hay ninguna líneaEfecto:tras ese cambio.
El problema del diamante
Cuando dos caminos del grafo salen de un mismo origen y se juntan después, se habla de un diamante. En sistemas reactivos simples, el nodo final puede ejecutarse dos veces, una de ellas con una mezcla de valores viejos y nuevos. Esa inconsistencia momentánea se llama glitch. Con señales no ocurre:
const base = signal(1);
const izquierda = computed(() => {
console.log(' izquierda');
return base() + 1;
});
const derecha = computed(() => {
console.log(' derecha');
return base() * 10;
});
const suma = computed(() => {
console.log(' suma');
return izquierda() + derecha();
});
console.log(suma());
base.set(2);
console.log(suma());
suma
izquierda
derecha
12
izquierda
suma
derecha
23
suma se ejecuta una vez por cada lectura, y nunca ve una izquierda nueva con una derecha vieja, porque el valor se obtiene al tirar, cuando ya está todo marcado.
La plantilla también es un consumidor
Cada componente tiene un consumidor reactivo asociado a su plantilla. Mientras Angular pinta la vista, ese consumidor está activo, de modo que cualquier señal que se lea en la plantilla queda registrada. Cuando esa señal cambia, Angular marca la vista para actualizarla y programa la actualización. Este es el mecanismo que permite trabajar sin Zone.js, y lo estudiarás en detalle en el módulo 09.
5. Igualdad
Cada escritura compara el valor nuevo con el anterior. Si son iguales, no pasa nada: ni sube la versión ni se avisa a nadie. La comparación por defecto es Object.is, que se comporta como === salvo en dos casos: NaN es igual a sí mismo, y 0 es distinto de -0.
const n = signal(1);
let ejecuciones = 0;
const doble = computed(() => {
ejecuciones++;
return n() * 2;
});
doble();
n.set(1); // mismo valor: no invalida nada
doble();
console.log('Mismo valor:', ejecuciones);
Mismo valor: 1
Con objetos, Object.is compara referencias, no contenido. Dos objetos con los mismos datos son distintos para la señal. Mira lo que ocurre en esta cadena:
const empleado = signal({ empId: 100, nombre: 'Ana' });
const nombre = computed(() => {
console.log(' calculando nombre');
return empleado().nombre;
});
const saludo = computed(() => {
console.log(' calculando saludo');
return `Hola, ${nombre()}`;
});
console.log(saludo());
empleado.set({ empId: 100, nombre: 'Ana' }); // objeto nuevo, mismo contenido
console.log(saludo());
calculando saludo
calculando nombre
Hola, Ana
calculando nombre
Hola, Ana
El objeto nuevo invalida nombre, que se recalcula. Pero nombre da otra vez la cadena 'Ana', y las cadenas se comparan por valor, así que saludo no se ejecuta. Esta es la versión real de la parada por igualdad de la sección anterior, y da una técnica muy útil: un computed que extrae un campo actúa de cortafuegos. Las plantillas que lean nombre() no se actualizarán aunque cambien otros campos de empleado.
La opción equal
signal, computed y linkedSignal aceptan una función de igualdad propia:
const conId = signal(
{ empId: 100, nombre: 'Ana' },
{ equal: (a, b) => a.empId === b.empId },
);
conId.set({ empId: 100, nombre: 'Ana María' });
console.log(conId().nombre);
Ana
Atención a este resultado: si equal dice que son iguales, el valor nuevo se descarta. No es solo que no se avise: la señal sigue guardando el objeto antiguo. Por eso equal en una señal escribible es peligroso cuando compara solo una parte del objeto.
Donde sí es útil y seguro es en un computed que devuelve objetos o listas nuevos en cada cálculo:
const idsActivos = computed(() => empleados().map((e) => e.empId), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});
map crea siempre una lista nueva, y sin equal todos los consumidores tendrían que actualizarse aunque los identificadores fueran los mismos.
6. Inmutabilidad
Las señales detectan cambios comparando referencias, así que mutar un objeto no es un cambio para ellas. Es el error más frecuente al empezar.
const nombres = signal(['Ana', 'Luis']);
const total = computed(() => nombres().length);
console.log(total());
nombres().push('Marta'); // la señal no se entera
console.log(nombres(), total());
nombres.update((lista) => [...lista]); // lista nueva: ahora sí
console.log(nombres(), total());
2
[ 'Ana', 'Luis', 'Marta' ] 2
[ 'Ana', 'Luis', 'Marta' ] 3
Tras el push, nombres() ya tiene tres elementos, pero total sigue diciendo 2: es el valor en caché, y nadie le ha dicho que esté obsoleto. La lista y su derivado están en desacuerdo.
En un componente, este fallo es aún más traicionero, porque puede parecer que funciona:
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-mutacion',
template: `
<h2>Lista de nombres</h2>
<ul>
@for (n of nombres(); track n) {
<li>{{ n }}</li>
}
</ul>
<p class="total">Total calculado: <strong>{{ total() }}</strong></p>
<button type="button" id="mutar" (click)="anadirMal()">Añadir mutando</button>
<button type="button" id="copiar" (click)="anadirBien()">Añadir con copia</button>
`,
})
export class Mutacion {
protected readonly nombres = signal(['Ana', 'Luis']);
protected readonly total = computed(() => this.nombres().length);
private siguiente = 1;
protected anadirMal(): void {
this.nombres().push(`Mutado ${this.siguiente++}`); // nadie se entera
}
protected anadirBien(): void {
this.nombres.update((lista) => [...lista, `Copiado ${this.siguiente++}`]);
}
}
Tras pulsar dos veces en «Añadir mutando»:

Figura 6. La lista crece y el total no: una señal mutada deja el grafo en un estado incoherente.
¿Por qué aparece el elemento? Porque un clic en un botón de la plantilla marca la vista para actualizarla, sea cual sea el motivo. Al repasar la plantilla, @for compara la lista con lo pintado y encuentra el elemento nuevo. Pero total no se recalcula, porque ninguna señal ha avisado. Hemos comprobado además que, si el mismo push se hace sin un evento de la plantilla (por ejemplo, desde un temporizador), la pantalla no cambia en absoluto.
Este es el motivo por el que estos fallos son tan confusos: el resultado depende de qué provocó el cambio. La regla es no mutar nunca el contenido de una señal.
Recetas de actualización inmutable
| Operación | Mal (muta) | Bien (crea un valor nuevo) |
|---|---|---|
| Añadir al final | lista().push(x) | lista.update((l) => [...l, x]) |
| Añadir al principio | lista().unshift(x) | lista.update((l) => [x, ...l]) |
| Quitar | lista().splice(i, 1) | lista.update((l) => l.filter((e) => e.id !== id)) |
| Cambiar un elemento | lista()[i].salario = 2000 | lista.update((l) => l.map((e) => (e.id === id ? { ...e, salario: 2000 } : e))) |
| Ordenar | lista().sort(comparar) | lista.update((l) => l.toSorted(comparar)) |
| Cambiar un campo | obj().activo = true | obj.update((o) => ({ ...o, activo: true })) |
| Objeto anidado | obj().dir.ciudad = 'Soria' | obj.update((o) => ({ ...o, dir: { ...o.dir, ciudad: 'Soria' } })) |
Si necesitas repasar el operador de propagación (...) o métodos como toSorted, el Curso de JavaScript los explica.
La copia es superficial: { ...o } crea un objeto nuevo, pero sus objetos internos se comparten. Es lo que queremos, porque copiar solo lo que cambia es barato, y permite que los computed que dependen de las partes intactas se detengan por igualdad.
💡 Consejo: Combina
readonlyen las interfaces (readonly salario: number) consignal<readonly Empleado[]>. Con esos tipos, las líneas de la columna «Mal» no compilan.
7. effect: salir del mundo de las señales
Las señales y los computed describen datos. Pero a veces un cambio tiene que producir algo fuera del grafo: guardar en localStorage, escribir en un registro o avisar a una biblioteca de gráficos. Eso es un efecto secundario (side effect), y para eso está effect.
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-preferencias',
template: `
<h2>Preferencias</h2>
<label>
<input
type="checkbox"
[checked]="oscuro()"
(change)="oscuro.set($any($event.target).checked)"
/>
Tema oscuro
</label>
<p>Filas por página: {{ filas() }}</p>
<button type="button" (click)="filas.set(25)">25 filas</button>
`,
})
export class Preferencias {
protected readonly oscuro = signal(localStorage.getItem('oscuro') === 'true');
protected readonly filas = signal(Number(localStorage.getItem('filas') ?? 10));
constructor() {
effect(() => {
localStorage.setItem('oscuro', String(this.oscuro()));
localStorage.setItem('filas', String(this.filas()));
});
}
}
Un efecto se ejecuta al menos una vez, y después cada vez que cambia alguna de las señales que leyó. Como computed, descubre sus dependencias al ejecutarse, y estas son dinámicas. A diferencia de computed, no es perezoso y no devuelve nada.
Cuándo se ejecuta
Un efecto nunca se ejecuta de forma síncrona al escribir en la señal: se programa. Este componente registra el orden de los acontecimientos:
import { afterNextRender, Component, effect, OnInit, signal } from '@angular/core';
@Component({ selector: 'app-ciclo', template: `{{ registrar() }}` })
export class Ciclo implements OnInit {
readonly n = signal(0);
constructor() {
console.log('constructor');
effect(() => console.log('efecto', this.n()));
afterNextRender(() => console.log('afterNextRender'));
}
ngOnInit(): void {
console.log('ngOnInit');
}
// Solo para ver el orden: una plantilla no debe tener efectos secundarios
registrar(): string {
console.log('plantilla', this.n());
return '';
}
}
Al crearlo, y después de esperar a que Angular termine, haciendo tres escrituras seguidas (n.set(1), n.set(2), n.set(3)):
constructor
ngOnInit
efecto 0
plantilla 0
afterNextRender
-- tres escrituras
tras escribir
efecto 3
plantilla 3
De aquí salen cuatro ideas:
- El efecto no corre en el constructor: corre después de
ngOnInit. Por eso, dentro de un efecto, losinput()del componente (que verás en el módulo 06) ya tienen valor. - Corre antes de actualizar la plantilla del componente.
- Tres escrituras producen una sola ejecución, con el último valor. Los valores intermedios 1 y 2 nunca se ven. Un efecto no es un registro de eventos: si necesitas cada valor, usa un método.
- Si necesitas tocar el DOM ya actualizado,
effectno es la herramienta: usaafterRenderEffectoafterNextRender, que viste en el módulo 03.
Según la documentación de la API, hay dos clases de efecto. Los efectos de componente se crean desde un componente, una directiva o un servicio ligado a ellos, y corren durante la sincronización de la vista, como en el ejemplo. Los efectos raíz se crean fuera del árbol de componentes, por ejemplo en un servicio de la raíz, y corren como microtareas, sin relación con los componentes. Tampoco estos son síncronos, y en nuestra prueba el orden fue efecto 0, sync, efecto 1.
Limpieza con onCleanup
La función del efecto recibe un parámetro para registrar una limpieza. Esta se ejecuta antes de la siguiente ejecución y cuando se destruye el efecto. Es imprescindible cuando el efecto abre algo que hay que cerrar. Sin ella, cada cambio de segundos añadiría un temporizador más:
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-cuenta-atras',
template: `
<label>
Segundos
<input
type="number"
[value]="segundos()"
(input)="segundos.set(+$any($event.target).value)"
/>
</label>
<p>Quedan {{ restante() }} segundos</p>
`,
})
export class CuentaAtras {
protected readonly segundos = signal(10);
protected readonly restante = signal(10);
constructor() {
effect((onCleanup) => {
const inicio = this.segundos(); // única dependencia
this.restante.set(inicio);
const id = setInterval(() => {
this.restante.update((r) => Math.max(0, r - 1));
}, 1000);
onCleanup(() => clearInterval(id));
});
}
}
El orden real, con un valor que cambia de 0 a 1 y un componente que después se destruye, es este:
corre 0
limpia 0
corre 1
destroyRef
limpia 1
Cada ejecución limpia lo de la anterior, y al destruir el componente se limpia lo de la última. El efecto se destruye solo con el componente: después, escribir en la señal ya no lo ejecuta.
Contexto de inyección y destrucción manual
effect necesita saber a qué pertenece para poder destruirse. Lo averigua con el sistema de inyección, así que debe llamarse dentro de un contexto de inyección: el constructor o un inicializador de campo. Si lo llamas desde otro sitio, por ejemplo un método, falla:
NG0203: effect() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
El módulo 08 explica ese contexto. Si de verdad necesitas crear un efecto más tarde, pásale el inyector. effect devuelve un EffectRef con el método destroy:
import { Component, effect, EffectRef, inject, Injector, signal } from '@angular/core';
@Component({
selector: 'app-seguimiento',
template: `
<button type="button" (click)="activar()">Activar</button>
<button type="button" (click)="desactivar()">Desactivar</button>
<button type="button" (click)="valor.update((v) => v + 1)">Sumar</button>
<p>{{ valor() }}</p>
`,
})
export class Seguimiento {
private readonly inyector = inject(Injector);
private referencia?: EffectRef;
protected readonly valor = signal(0);
protected activar(): void {
this.referencia ??= effect(() => console.log('Valor:', this.valor()), {
injector: this.inyector,
});
}
protected desactivar(): void {
this.referencia?.destroy();
this.referencia = undefined;
}
}
Pulsando Sumar, Activar, Sumar, Desactivar y Sumar, la pantalla termina en 3 y la consola solo muestra Valor: 1 y Valor: 2.
Opción de effect | Efecto |
|---|---|
injector | Inyector que se usa en lugar del contexto actual |
manualCleanup: true | No se destruye con su propietario, solo al llamar a destroy(). En nuestra prueba siguió ejecutándose tras destruir el inyector |
debugName | Nombre con el que lo identifica Angular DevTools |
allowSignalWrites | Obsoleta. Ya no hace falta: escribir señales en un efecto está permitido. La verás en código antiguo |
Trampas de los efectos
Las lecturas tras un await no se rastrean. El rastreo solo está activo durante la parte síncrona de la función. Todo lo que venga después de un await, o dentro de un setTimeout, ya no cuenta:
effect(async () => {
a(); // dependencia
await Promise.resolve();
b(); // NO es dependencia
});
Lo hemos medido: cambiar b no vuelve a ejecutar el efecto, y cambiar a sí. La solución es leer todas las señales al principio y usar después los valores guardados en constantes. Para datos asíncronos, lo adecuado no es un efecto, sino los resources del módulo 13.
Un efecto que lee y escribe la misma señal se llama a sí mismo. Con este componente, en un navegador real, la pestaña se quedó bloqueada y no llegó a mostrar ningún error en los siete segundos que esperamos:
effect(() => {
this.n.set(this.n() + 1); // ¡nunca termina!
});
Curiosamente, update no tiene ese problema, porque lee el valor anterior sin registrar la dependencia. effect(() => n.update((x) => x + 1)) se ejecutó una sola vez en nuestras pruebas.
Un error no mata el efecto. Si la función lanza una excepción con cierto valor, la siguiente vez que cambie la señal se volverá a ejecutar con normalidad. Lo hemos comprobado con un efecto que fallaba solo con un valor concreto.
No uses un efecto para sincronizar estado
Este es el mal uso más habitual:
// MAL
const origen = signal(1);
const copia = signal(0);
effect(() => copia.set(origen()));
// BIEN
const derivada = computed(() => origen());
Además de ser más largo, tiene un fallo real: va con retraso. En nuestra prueba, justo después de origen.set(2), copia() seguía valiendo 1 y derivada() ya valía 2. Durante un instante, la aplicación tiene dos versiones de la verdad, y cualquier código que lea copia en ese momento verá un dato viejo. computed no tiene ese problema, porque se calcula al leer.
💡 Consejo: Antes de escribir
effect, pregúntate si estás calculando un dato o si estás actuando sobre el mundo exterior. Si estás calculando, necesitascomputedolinkedSignal.
8. untracked: leer sin depender
A veces, dentro de un cálculo reactivo, necesitas el valor de una señal pero no quieres reaccionar a sus cambios. untracked ejecuta una función con el rastreo desactivado:
const a = signal(1);
const b = signal(10);
const c = computed(() => {
console.log(' calculando c');
return a() + untracked(b);
});
console.log(c());
b.set(20); // no provoca nada
console.log(c());
a.set(2); // recalcula, y entonces ve el b actual
console.log(c());
calculando c
11
11
calculando c
22
Cuidado con la segunda línea 11: b vale 20, pero c no lo refleja. En un computed esto suele ser un error. Su lugar natural es un efecto, donde quieres elegir exactamente qué lo dispara:
import { Component, effect, signal, untracked } from '@angular/core';
@Component({
selector: 'app-registro-seleccion',
template: `<p>{{ seleccionado() }}</p>`,
})
export class RegistroSeleccion {
protected readonly seleccionado = signal('Ana');
protected readonly visitas = signal(0);
readonly mensajes: string[] = [];
constructor() {
effect(() => {
const nombre = this.seleccionado();
// Escribir aquí sin untracked haría del contador una dependencia
const visitas = untracked(this.visitas);
this.mensajes.push(`${nombre}: ${visitas} visitas`);
});
}
}
Con dos cambios de visitas y luego uno de seleccionado, mensajes contiene:
Ana: 0 visitas
Luis: 2 visitas
El uso más importante de untracked es el menos visible: proteger un efecto de las funciones que llama. Si dentro de un efecto llamas a un método que lee señales, esas lecturas cuentan como dependencias del efecto, aunque tú no las veas:
const a = signal(1);
const oculta = signal(5);
function ayudante() {
return oculta();
}
effect(() => {
a();
ayudante(); // el efecto pasa a depender de oculta
});
Lo comprobamos: cambiar oculta vuelve a ejecutar el efecto. Si no es lo que quieres, llama a untracked(() => ayudante()). Cuanto más largo sea el código que llama un efecto, más te conviene envolverlo.
9. linkedSignal: estado editable que depende de otro
Hasta ahora, un valor era o derivado (computed, no se puede escribir) o independiente (signal, no depende de nada). Pero hay un caso muy frecuente que está en medio: un valor que tiene una opción por defecto calculada, que el usuario puede cambiar, y que debe reiniciarse si cambia aquello de lo que depende.
Piensa en una lista de departamentos y una lista de sus empleados. Al cambiar de departamento, el empleado elegido debe pasar a ser el primero del nuevo; dentro del mismo departamento, el usuario elige el que quiera. computed no sirve, porque no se puede escribir. signal con un effect que lo reinicie sería la sincronización que acabamos de desaconsejar. linkedSignal resuelve exactamente esto.
const opciones = signal(['Estándar', 'Exprés', 'Recogida']);
const elegida = linkedSignal(() => opciones()[0]);
console.log(elegida());
elegida.set('Exprés'); // se puede escribir, como una señal normal
console.log(elegida());
opciones.set(['Correos', 'Mensajería']); // manda de nuevo el cálculo
console.log(elegida());
Estándar
Exprés
Correos
Un linkedSignal es un WritableSignal: se escribe con set y update. Lo que escribes dura hasta que cambia una dependencia.
Figura 7. El cálculo manda al inicio y tras cada cambio de sus dependencias; en medio, manda lo que escribas.
Aplicado a la aplicación EMPRESA, usando datos.ts:
import { Component, computed, linkedSignal, signal } from '@angular/core';
import { DEPARTAMENTOS, EMPLEADOS, Empleado } from './datos';
@Component({
selector: 'app-selector-empleado',
template: `
<h2>Ficha por departamento</h2>
<div class="botones">
@for (d of departamentos; track d.deptId) {
<button
type="button"
[class.activo]="d.deptId === deptId()"
(click)="deptId.set(d.deptId)"
>
{{ d.nombre }}
</button>
}
</div>
<ul>
@for (e of plantilla(); track e.empId) {
<li [class.elegido]="e.empId === elegido()?.empId" (click)="elegido.set(e)">
{{ e.nombre }} {{ e.apellidos }}
</li>
}
</ul>
<p class="ficha">Seleccionado: <strong>{{ elegido()?.nombre }}</strong></p>
`,
styles: `
:host { display: block; font-family: 'Segoe UI', Arial, sans-serif; color: #37474f; }
h2 { color: #1a237e; margin-top: 0; }
.botones { display: flex; gap: .5rem; }
.activo { background: #3f51b5; color: #fff; }
li { cursor: pointer; padding: .15rem .4rem; }
.elegido { background: #e8eaf6; font-weight: 600; }
`,
})
export class SelectorEmpleado {
protected readonly departamentos = DEPARTAMENTOS;
protected readonly deptId = signal(20);
protected readonly plantilla = computed(() =>
EMPLEADOS.filter((e) => e.deptId === this.deptId()),
);
// Vale el primero de la plantilla, pero el usuario puede cambiarlo
protected readonly elegido = linkedSignal<Empleado | undefined>(
() => this.plantilla()[0],
);
}

Figura 8. El usuario ha elegido a Elena: elegido guarda lo escrito a mano.

Figura 9. Al pulsar INFORMÁTICA, elegido vuelve al primero de la nueva plantilla sin que ningún código lo reinicie.
Forma larga: source, computation y el valor anterior
Reiniciar no siempre es lo que quieres. Si recargas la lista de empleados y el elegido sigue en ella, lo razonable es conservarlo. Para eso, la forma larga separa de qué se depende (source) y cómo se calcula el valor (computation), y le pasa a la función el estado anterior:
interface Persona {
id: number;
nombre: string;
}
const candidatos = signal<Persona[]>([
{ id: 1, nombre: 'Ana' },
{ id: 2, nombre: 'Luis' },
]);
const seleccion = linkedSignal<Persona[], Persona | null>({
source: candidatos,
computation: (nuevos, previo) => {
console.log(' previo:', previo?.value?.nombre, 'de', previo?.source.length);
return nuevos.find((c) => c.id === previo?.value?.id) ?? nuevos[0] ?? null;
},
});
console.log(seleccion()?.nombre);
seleccion.set(candidatos()[1]);
candidatos.set([{ id: 2, nombre: 'Luis Martínez' }, { id: 3, nombre: 'Eva' }]);
console.log(seleccion()?.nombre);
candidatos.set([{ id: 3, nombre: 'Eva' }]);
console.log(seleccion()?.nombre);
previo: undefined de undefined
Ana
previo: Luis de 2
Luis Martínez
previo: Luis Martínez de 2
Eva
Parámetro de computation | Contenido |
|---|---|
nuevos (primero) | Valor actual de source |
previo (segundo, opcional) | undefined la primera vez; después { source, value } con la fuente anterior y el valor anterior |
Observa que previo.value incluye lo que se escribió a mano: la segunda salida muestra previo: Luis, y Luis se eligió con set. Y observa también que no devolvemos el objeto anterior, sino el de la lista nueva, con su nombre actualizado.
Si computation lee otras señales que no están en source, también serán dependencias, como explica el comentario de la propia API: el cálculo es reactivo.
La opción set 🆕 v22.1
🆕 Desde Angular 22.1. No existe en la LTS (Angular 21): el compilador da el error
TS2353. Si usas la LTS: usa uncomputedy un método propio que escriba en la señal original, como se muestra al final de este apartado.
Normalmente, escribir en un linkedSignal guarda el valor dentro de él. A veces eso no es lo que quieres: el valor es solo otra forma de ver un dato, y escribir debería cambiar el dato original. La opción set intercepta las escrituras:
import { Component, linkedSignal, signal } from '@angular/core';
@Component({
selector: 'app-conversor-salario',
template: `
<h2>Salario</h2>
<label>
Mensual
<input
id="mensual"
type="number"
[value]="mensual()"
(input)="mensual.set(+$any($event.target).value)"
/>
</label>
<label>
Anual (14 pagas)
<input
id="anual"
type="number"
[value]="anual()"
(input)="anual.set(+$any($event.target).value)"
/>
</label>
`,
styles: `
:host { display: grid; gap: .6rem; font-family: 'Segoe UI', Arial, sans-serif; color: #37474f; }
h2 { color: #1a237e; margin: 0; }
label { display: grid; gap: .25rem; max-width: 16rem; }
`,
})
export class ConversorSalario {
protected readonly mensual = signal(2000);
protected readonly anual = linkedSignal(() => this.mensual() * 14, {
set: (nuevoAnual) => this.mensual.set(nuevoAnual / 14),
});
}

Figura 10. Escribir 35000 en el campo anual actualiza la señal mensual, que vale 2500.
Con esto hay una sola fuente de la verdad, mensual, y anual se comporta como una propiedad con getter y setter. Los valores que medimos en este componente son: al inicio 2000 y 28000; tras escribir 35000 en el anual, 2500 y 35000; y tras escribir 3000 en el mensual, 3000 y 42000.
La función set recibe un segundo parámetro, rawSet, que escribe directamente en el linkedSignal, con el comportamiento habitual. Si no lo llamas, como en el conversor, el valor no se guarda: se obtiene al recalcularse a partir de mensual. Con rawSet puedes decidir qué escrituras se aceptan, por ejemplo para validar:
const paginas = signal(10);
const pagina = linkedSignal(() => { paginas(); console.log(' cálculo'); return 1; }, {
set: (nueva, rawSet) => {
if (nueva >= 1 && nueva <= paginas()) rawSet(nueva);
},
});
console.log(pagina());
pagina.set(5);
console.log(pagina());
pagina.set(99); // fuera de rango: se ignora
console.log(pagina());
pagina.update((p) => p + 1);
console.log(pagina());
paginas.set(3);
console.log(pagina());
cálculo
1
5
5
6
cálculo
1
update pasa también por esta función, como ves en la cuarta lectura, y lo comprobarás en el ejercicio 9.
⚠️ Importante: La documentación presenta
rawSetcomo una forma de evitar el cálculo cuando ya conoces el resultado. Eso solo vale si no escribes también en una dependencia. Si dentro desethacesneto.set(...)yrawSet(...), el cálculo se repite igualmente: lo medimos en Angular 22.2.4, porque la dependencia ha cambiado.
Alternativa en la LTS. Esta versión del componente compila en Angular 21.2. La plantilla llama a un método en lugar de anual.set:
protected readonly mensual = signal(2000);
protected readonly anual = computed(() => this.mensual() * 14);
protected cambiarAnual(nuevoAnual: number): void {
this.mensual.set(nuevoAnual / 14);
}
<input type="number" [value]="anual()" (input)="cambiarAnual(+$any($event.target).value)" />
Lo único que pierdes es poder entregar anual como una señal escribible a otro código, por ejemplo a un model() del módulo 06.
10. debounced: retardar una señal 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21), y en 22 sigue siendo experimental: puede cambiar. Si usas la LTS: usa la función
conRetardode esta sección, hecha coneffectysetTimeout, otoObservablecondebounceTime(módulo 15). Solo pierdes el estadostatus().
Si cada pulsación de tecla en un buscador lanza una búsqueda, escribir «martínez» hace ocho peticiones. Lo que quieres es esperar a que el usuario deje de escribir durante un momento. Esta técnica se llama debounce: cada cambio reinicia un temporizador, y solo cuando este termina se acepta el último valor.
debounced recibe una señal y un tiempo, o una función de espera, y devuelve un Resource, el tipo que estudiarás en el módulo 13. De momento bastan tres cosas: value() con el último valor ya aceptado, status() con el estado, e isLoading().
import { Component, computed, debounced, signal } from '@angular/core';
import { EMPLEADOS } from './datos';
@Component({
selector: 'app-buscador',
template: `
<h2>Buscador</h2>
<label>
Nombre
<input
type="search"
[value]="texto()"
(input)="texto.set($any($event.target).value)"
/>
</label>
<p class="estado">
Escrito: <code>{{ texto() }}</code> ·
Buscado: <code>{{ busqueda.value() }}</code> ·
Estado: <code>{{ busqueda.status() }}</code>
</p>
<ul>
@for (e of resultados(); track e.empId) {
<li>{{ e.nombre }} {{ e.apellidos }}</li>
}
</ul>
`,
styles: `
:host { display: block; font-family: 'Segoe UI', Arial, sans-serif; color: #37474f; }
h2 { color: #1a237e; margin-top: 0; }
`,
})
export class Buscador {
protected readonly texto = signal('');
protected readonly busqueda = debounced(this.texto, 400);
protected readonly resultados = computed(() => {
const buscado = this.busqueda.value().toLowerCase();
return EMPLEADOS.filter((e) => e.nombre.toLowerCase().includes(buscado));
});
}

Figura 11. Durante la espera, status() es loading y value() conserva el último valor aceptado.

Figura 12. Pasados 400 ms sin cambios, el valor se acepta y status() vuelve a resolved.
Esto es lo que medimos con este componente:
| Momento | status() | value() | isLoading() |
|---|---|---|---|
| Al crearlo | resolved | '' | false |
| Escribiendo, con el temporizador en marcha | loading | '' | true |
| 400 ms después de la última tecla | resolved | 'an' | false |
Otros detalles comprobados:
- Exige contexto de inyección. Fuera de él da NG0203. Si es necesario, acepta la opción
injector, comoeffect. - Si la señal lanza un error,
status()pasa aerrorsin esperar al temporizador, y leervalue()lanza una excepción. Es un comportamiento general de losResource. - Acepta
equalen su tercer parámetro. Por defecto compara conObject.is.
Espera variable
En lugar de milisegundos, puedes pasar una función que devuelva una promesa. Recibe el valor nuevo y una instantánea (snapshot) del estado anterior, y el valor se acepta cuando la promesa se resuelve. Si la fuente cambia antes, esa espera se descarta. Si la función no devuelve una promesa, el valor se acepta sin esperar:
readonly busqueda = debounced(this.texto, (valor, anterior) => {
if (anterior.status === 'error') return; // tras un error, sin espera
const ms = valor.length < 3 ? 500 : 200;
return new Promise<void>((resolver) => setTimeout(resolver, ms));
});
Alternativa con señales básicas
Si usas la LTS, o si prefieres no depender de una API experimental, esta función hace lo principal con lo que ya sabes. Compila y funciona en Angular 21.2 y en 22:
import { effect, signal, Signal, untracked } from '@angular/core';
export function conRetardo<T>(fuente: Signal<T>, ms: number): Signal<T> {
const salida = signal(untracked(fuente));
effect((onCleanup) => {
const valor = fuente();
const id = setTimeout(() => salida.set(valor), ms);
onCleanup(() => clearTimeout(id));
});
return salida.asReadonly();
}
Cada cambio de fuente cancela con onCleanup el temporizador anterior, que es exactamente el comportamiento de un debounce. Como usa effect, debes llamarla en un contexto de inyección:
protected readonly texto = signal('');
protected readonly busqueda = conRetardo(this.texto, 400);
protected readonly esperando = computed(() => this.texto() !== this.busqueda());
11. Depuración y errores frecuentes
Qué primitiva elegir
La mayoría de los problemas con señales nacen de elegir mal la herramienta. Este esquema resuelve casi todos los casos:
Figura 13. Cómo elegir entre signal, computed, linkedSignal y effect.
| Situación | Herramienta |
|---|---|
| Texto de un filtro, pestaña activa, página actual | signal |
| Total de la cesta, lista filtrada, nombre completo | computed |
| Opción elegida que se reinicia si cambian las opciones | linkedSignal |
Guardar en localStorage, enviar un registro, dibujar en un <canvas> | effect |
| Pedir datos a un servidor | httpResource (módulo 13) |
| Combinar flujos complejos de eventos | RxJS (módulo 14) |
Errores y qué significan
| Mensaje | Qué ha pasado | Solución |
|---|---|---|
NG0600: Writing to signals is not allowed in a computed | Has escrito en una señal dentro de un computed | Un computed solo calcula. Mueve la escritura a un método o a un efecto |
NG0203: effect() can only be used within an injection context | Se ha llamado a effect o debounced fuera del constructor | Muévelo al constructor o pasa la opción injector |
NG0602: ... cannot be called from within a reactive context | Una función que no admite un contexto reactivo se ha llamado desde un computed o un efecto | Llámala desde otro sitio o envuélvela en untracked |
Detected cycle in computations. | Un computed se lee a sí mismo, directa o indirectamente | Revisa el grafo: las dependencias no pueden formar un círculo |
NG8109: cuenta is a function and should be invoked (aviso) | Falta () en una señal, en la plantilla | Escribe cuenta() |
NG8117: Function in text interpolation should be invoked (aviso) | Mismo fallo, dentro de {{ }}; aparece junto al anterior | Escribe cuenta() |
TS2774: This condition will always return true | @if (visible) sin paréntesis: una función siempre es verdadera | Escribe @if (visible()) |
TS2339: Property 'set' does not exist on type 'Signal<number>' | Intentas escribir en un computed o en una vista de solo lectura | Escribe en la señal original o usa linkedSignal |
Estos mensajes están copiados de lo que produce Angular 22.2. NG0600 incluye además el nombre de la señal: en la prueba, Signal: 'otra'.
El caso de @if (visible) merece atención. Con {{ cuenta }} verías algo extraño en la pantalla, pero @if (visible) no da pistas en el navegador, porque una función siempre es verdadera y el bloque se mostraría siempre. Por eso conviene no ignorar nunca los avisos de compilación.
assertNotInReactiveContext
Si escribes una función que no debe llamarse desde un computed o un efecto, por ejemplo porque crea suscripciones, puedes hacer que falle de forma clara:
function abrirConexion() {
assertNotInReactiveContext(abrirConexion, 'Llámala desde un evento.');
// ...
}
NG0602: abrirConexion() cannot be called from within a reactive context. Llámala desde un evento.
Los errores también se guardan en caché
Si la función de un computed lanza una excepción, Angular memoriza el error. Se relanza en cada lectura sin repetir el cálculo, hasta que cambie una dependencia:
const divisor = signal(0);
let intentos = 0;
const division = computed(() => {
intentos++;
if (divisor() === 0) throw new Error('División entre cero');
return 100 / divisor();
});
Leyendo dos veces con try/catch, y después cambiando el divisor a 5:
División entre cero intentos: 1
División entre cero intentos: 1
20 intentos: 2
Dar nombre a las señales
signal, computed, linkedSignal y effect aceptan la opción debugName, que sirve para identificarlas en Angular DevTools:
const total = computed(() => suma(), { debugName: 'totalCesta' });
Lista de comprobación cuando algo no se actualiza
- ¿Has mutado un objeto o una lista en vez de crear uno nuevo? Es la causa más frecuente.
- ¿Falta
()en la plantilla? Revisa los avisos del compilador. - ¿El dato que cambia es una señal? Una variable normal no avisa a nadie.
- ¿Has leído la señal dentro del
computedo del efecto? Una lectura fuera, tras unawaito en una rama que no se ejecuta no crea dependencia. - ¿Tiene la señal una opción
equalque considera iguales los dos valores? Entonces se descarta el nuevo. - ¿Hay un
untrackedque oculta la dependencia?
12. Ejercicios
Los ejercicios usan datos.ts, de la sección 3. Los componentes se pueden probar añadiéndolos a App, y las clases y funciones, en pruebas.ts.
Ejercicio 1: Contador con límites
Crea un componente ContadorLimites con un valor entre 0 y 5, y botones - y +. Cada botón debe desactivarse al llegar a su tope, y el valor nunca debe salir del rango.
Ver solución
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-contador-limites',
template: `
<button type="button" (click)="cambiar(-1)" [disabled]="enMinimo()">-</button>
<output [class.tope]="enMinimo() || enMaximo()">{{ valor() }}</output>
<button type="button" (click)="cambiar(1)" [disabled]="enMaximo()">+</button>
`,
})
export class ContadorLimites {
private readonly minimo = 0;
private readonly maximo = 5;
protected readonly valor = signal(0);
protected readonly enMinimo = computed(() => this.valor() <= this.minimo);
protected readonly enMaximo = computed(() => this.valor() >= this.maximo);
protected cambiar(paso: number): void {
this.valor.update((v) =>
Math.min(this.maximo, Math.max(this.minimo, v + paso)),
);
}
}
enMinimo y enMaximo son derivados, así que no pueden desincronizarse. El límite se aplica además en cambiar: el atributo disabled no debe ser la única defensa. Con siete clics en +, el valor se queda en 5 y el botón + queda desactivado.
Ejercicio 2: Cesta de la compra inmutable
Crea una clase Cesta con estado privado y lineas de solo lectura. Debe tener anadir(producto), que suma una unidad si ya está en la cesta, y quitar(productoId), además de los derivados unidades y total. No debe mutar nunca el contenido.
Ver solución
import { computed, signal } from '@angular/core';
export interface Producto {
readonly productoId: number;
readonly nombre: string;
readonly precio: number;
}
export interface LineaCesta {
readonly producto: Producto;
readonly cantidad: number;
}
export class Cesta {
readonly #lineas = signal<readonly LineaCesta[]>([]);
readonly lineas = this.#lineas.asReadonly();
readonly unidades = computed(() =>
this.#lineas().reduce((suma, l) => suma + l.cantidad, 0),
);
readonly total = computed(() =>
this.#lineas().reduce((suma, l) => suma + l.producto.precio * l.cantidad, 0),
);
anadir(producto: Producto): void {
this.#lineas.update((lineas) => {
const existe = lineas.some((l) => l.producto.productoId === producto.productoId);
return existe
? lineas.map((l) =>
l.producto.productoId === producto.productoId
? { ...l, cantidad: l.cantidad + 1 }
: l,
)
: [...lineas, { producto, cantidad: 1 }];
});
}
quitar(productoId: number): void {
this.#lineas.update((lineas) =>
lineas.filter((l) => l.producto.productoId !== productoId),
);
}
}
Con un portátil de 899 € y dos ratones de 19,90 €, unidades() da 3 y total() da 938.8. Al quitar el portátil, quedan 2 unidades y 39.8. Fíjate en que map conserva las líneas que no cambian: solo se crea un objeto nuevo para la línea modificada.
Ejercicio 3: Informe por oficios
Escribe crearInforme(fuente), que recibe la lista de empleados y devuelve una señal salarioMinimo, un derivado seleccion con los empleados que lo alcanzan, y un derivado porOficio con pares [oficio, cantidad] ordenados de mayor a menor cantidad.
Ver solución
import { computed, signal } from '@angular/core';
import { Empleado } from './datos';
export function crearInforme(fuente: readonly Empleado[]) {
const salarioMinimo = signal(0);
const seleccion = computed(() =>
fuente.filter((e) => e.salario >= salarioMinimo()),
);
const porOficio = computed(() => {
const grupos = new Map<string, number>();
for (const e of seleccion()) {
grupos.set(e.oficio, (grupos.get(e.oficio) ?? 0) + 1);
}
return [...grupos].sort((a, b) => b[1] - a[1]);
});
return { salarioMinimo, seleccion, porOficio };
}
Con EMPLEADOS, y al subir el mínimo a 3000:
[["VENDEDOR",4],["DIRECTOR",2],["DIRECTOR GENERAL",1],["ADMINISTRATIVO",1],["ANALISTA",1]]
[["DIRECTOR",2],["DIRECTOR GENERAL",1],["ANALISTA",1]]
Las señales no necesitan una clase: una función que devuelve señales es una forma limpia de reutilizar lógica.
Ejercicio 4: Caza de fallos
Este componente compila con un aviso, pero tiene tres fallos. Encuéntralos y corrígelos:
@Component({
selector: 'app-lista-tareas',
template: `
<p>{{ aviso }}</p>
<button type="button" (click)="completar(1)">Completar la primera</button>
`,
})
export class ListaTareas {
protected readonly tareas = signal([
{ id: 1, texto: 'Revisar nóminas', hecha: false },
{ id: 2, texto: 'Cerrar el trimestre', hecha: false },
]);
protected readonly pendientes = computed(
() => this.tareas().filter((t) => !t.hecha).length,
);
protected readonly aviso = signal('');
constructor() {
effect(() => this.aviso.set(`Quedan ${this.pendientes()} tareas`));
}
protected completar(id: number): void {
this.tareas().find((t) => t.id === id)!.hecha = true;
}
}
Ver solución
{{ aviso }}sin paréntesis. Muestra la función, y es el aviso NG8109.completarmuta el objeto. La señal no se entera, ypendientesconserva su valor en caché.avisoes un dato derivado guardado con un efecto. Debe ser uncomputed, y así desaparece también el constructor.
@Component({
selector: 'app-lista-tareas',
template: `
<p>{{ aviso() }}</p>
<button type="button" (click)="completar(1)">Completar la primera</button>
`,
})
export class ListaTareas {
protected readonly tareas = signal([
{ id: 1, texto: 'Revisar nóminas', hecha: false },
{ id: 2, texto: 'Cerrar el trimestre', hecha: false },
]);
protected readonly pendientes = computed(
() => this.tareas().filter((t) => !t.hecha).length,
);
protected readonly aviso = computed(() => `Quedan ${this.pendientes()} tareas`);
protected completar(id: number): void {
this.tareas.update((tareas) =>
tareas.map((t) => (t.id === id ? { ...t, hecha: true } : t)),
);
}
}
Tras el clic, el párrafo pasa de Quedan 2 tareas a Quedan 1 tareas.
Ejercicio 5: Nota con guardado automático
Crea un componente con un textarea cuyo contenido se recupere de localStorage al abrirlo, y se guarde 500 ms después de la última pulsación. Muestra también el número de caracteres, sin retardo.
Ver solución
import { Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-nota-persistente',
template: `
<textarea
[value]="nota()"
(input)="nota.set($any($event.target).value)"
></textarea>
<p>{{ longitud() }} caracteres</p>
`,
})
export class NotaPersistente {
protected readonly nota = signal(localStorage.getItem('nota') ?? '');
protected readonly longitud = computed(() => this.nota().length);
constructor() {
effect((onCleanup) => {
const texto = this.nota();
const id = setTimeout(() => localStorage.setItem('nota', texto), 500);
onCleanup(() => clearTimeout(id));
});
}
}
Tras escribir «hola», el contador marca 4 de inmediato y localStorage sigue vacío; 500 ms después, contiene hola. Es un buen uso de un efecto, porque guardar es un efecto secundario, y onCleanup cancela el guardado pendiente.
Ejercicio 6: Paginación que se reinicia
Muestra EMPLEADOS de 3 en 3, con un filtro por nombre y botones Anterior y Siguiente. Al cambiar el filtro, debe volver a la página 1, sin efectos.
Ver solución
import { Component, computed, linkedSignal, signal } from '@angular/core';
import { EMPLEADOS } from './datos';
@Component({
selector: 'app-lista-paginada',
template: `
<input
type="search"
[value]="filtro()"
(input)="filtro.set($any($event.target).value)"
/>
<ul>
@for (e of pagina(); track e.empId) {
<li>{{ e.nombre }}</li>
}
</ul>
<button type="button" (click)="numero.update((n) => n - 1)" [disabled]="numero() === 1">
Anterior
</button>
<span>Página {{ numero() }} de {{ paginas() }}</span>
<button
type="button"
(click)="numero.update((n) => n + 1)"
[disabled]="numero() >= paginas()"
>
Siguiente
</button>
`,
})
export class ListaPaginada {
private readonly tamano = 3;
protected readonly filtro = signal('');
protected readonly coincidencias = computed(() => {
const texto = this.filtro().toLowerCase();
return EMPLEADOS.filter((e) => e.nombre.toLowerCase().includes(texto));
});
protected readonly paginas = computed(() =>
Math.max(1, Math.ceil(this.coincidencias().length / this.tamano)),
);
// Cada vez que cambia el filtro, vuelve a la página 1
protected readonly numero = linkedSignal({
source: this.filtro,
computation: () => 1,
});
protected readonly pagina = computed(() => {
const inicio = (this.numero() - 1) * this.tamano;
return this.coincidencias().slice(inicio, inicio + this.tamano);
});
}
La salida al avanzar a la página 3 y filtrar después por a:
Página 1 de 3 Ana,Luis,Marta
Página 3 de 3 Sergio,Lucía,Pablo
Página 1 de 3 Ana,Marta,Carmen
Una computation que devuelve una constante es la forma más corta de decir «reinicia cuando cambie esto».
Ejercicio 7: Conservar la selección
Escribe crearSeleccion(lista), que recibe una señal con empleados y devuelve un linkedSignal con el elegido. Al cambiar la lista, debe conservar al empleado si sigue en ella (buscándolo por empId). Si no, elige el primero, o null si la lista está vacía.
Ver solución
import { linkedSignal, Signal } from '@angular/core';
import { Empleado } from './datos';
export function crearSeleccion(lista: Signal<readonly Empleado[]>) {
return linkedSignal<readonly Empleado[], Empleado | null>({
source: lista,
computation: (nueva, previa) =>
nueva.find((e) => e.empId === previa?.value?.empId) ?? nueva[0] ?? null,
});
}
Con una lista de los tres primeros empleados, se elige a Luis y la lista pasa a ser Luis, Marta y Carmen: se conserva a Luis. Si la lista pasa a ser Carmen y David, se elige a Carmen. Con la lista vacía, el resultado es null.
Cuando el tipo no se puede inferir, como aquí con Empleado | null, hay que indicar los dos genéricos: el de la fuente y el del valor.
Ejercicio 8: Deshacer y rehacer
Crea una clase genérica Historial<T> con el valor actual de solo lectura, los métodos fijar, deshacer y rehacer, y los derivados puedeDeshacer y puedeRehacer. Fijar un valor nuevo debe vaciar lo que se podía rehacer.
Ver solución
import { computed, signal, Signal, WritableSignal } from '@angular/core';
export class Historial<T> {
readonly #pasado = signal<readonly T[]>([]);
readonly #presente: WritableSignal<T>;
readonly #futuro = signal<readonly T[]>([]);
readonly valor: Signal<T>;
readonly puedeDeshacer = computed(() => this.#pasado().length > 0);
readonly puedeRehacer = computed(() => this.#futuro().length > 0);
constructor(inicial: T) {
this.#presente = signal(inicial);
this.valor = this.#presente.asReadonly();
}
fijar(nuevo: T): void {
const actual = this.#presente();
if (Object.is(actual, nuevo)) return;
this.#pasado.update((p) => [...p, actual]);
this.#presente.set(nuevo);
this.#futuro.set([]);
}
deshacer(): void {
const pasado = this.#pasado();
if (pasado.length === 0) return;
this.#futuro.update((f) => [this.#presente(), ...f]);
this.#presente.set(pasado[pasado.length - 1]);
this.#pasado.set(pasado.slice(0, -1));
}
rehacer(): void {
const futuro = this.#futuro();
if (futuro.length === 0) return;
this.#pasado.update((p) => [...p, this.#presente()]);
this.#presente.set(futuro[0]);
this.#futuro.set(futuro.slice(1));
}
}
Con new Historial('a'), fijar('b'), fijar('c') y dos veces deshacer(), el valor es a, puedeDeshacer() es false y puedeRehacer() es true. rehacer() da b, y fijar('z') da z con puedeRehacer() en false.
Como en cada operación se escriben varias señales seguidas, puede parecer que un consumidor vería un estado a medias. No ocurre: las escrituras solo marcan, y los efectos y las plantillas actúan al terminar el código síncrono.
Ejercicio 9: Precio con IVA 🆕 v22.1
🆕 Desde Angular 22.1. No existe en la LTS (Angular 21): usa la opción
setdelinkedSignal. Si usas la LTS: devuelve uncomputedcon el precio y una función que escriba en la señal del neto, como en la solución.
Escribe crearPrecio(iva, inicial), donde iva es una señal con el porcentaje. Debe devolver neto, una señal escribible, y conIva, una señal escribible que al recibir un valor actualice neto. Comprueba qué ocurre con update.
Ver solución
import { linkedSignal, signal, Signal } from '@angular/core';
export function crearPrecio(iva: Signal<number>, inicial: number) {
const neto = signal(inicial);
const conIva = linkedSignal(() => neto() * (1 + iva() / 100), {
set: (bruto) => neto.set(bruto / (1 + iva() / 100)),
});
return { neto, conIva };
}
Partiendo de 100 € y un 21 %:
| Operación | Resultado |
|---|---|
conIva() | 121 |
conIva.set(242) | neto() es 200, conIva() es 242 |
conIva.update((v) => v + 121) | neto() es 300 |
iva.set(10) | conIva() es 330 |
update pasa también por la función set, como muestra la tercera fila.
Versión para la LTS, que compila en Angular 21.2:
import { computed, signal, Signal } from '@angular/core';
export function crearPrecio(iva: Signal<number>, inicial: number) {
const neto = signal(inicial);
const conIva = computed(() => neto() * (1 + iva() / 100));
const fijarConIva = (bruto: number) => neto.set(bruto / (1 + iva() / 100));
return { neto, conIva, fijarConIva };
}
Ejercicio 10: Buscador con espera variable 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21), y sigue siendo experimental: usa
debounced. Si usas la LTS: usaconRetardocon un tiempo fijo y calcula el estado comparando el texto con su versión retardada, como en la solución.
Crea un buscador que espere 600 ms si el texto tiene menos de 3 caracteres, y 200 ms en caso contrario. Debe ignorar espacios sobrantes y mayúsculas, y mostrar «Esperando…» mientras no haya un valor aceptado.
Ver solución
import { Component, computed, debounced, signal } from '@angular/core';
import { EMPLEADOS } from './datos';
@Component({
selector: 'app-buscador-estado',
template: `
<input
type="search"
[value]="texto()"
(input)="texto.set($any($event.target).value)"
/>
@if (buscando()) {
<p class="estado">Esperando a que termines de escribir...</p>
} @else {
<p class="estado">{{ encontrados().length }} resultados</p>
}
<ul>
@for (e of encontrados(); track e.empId) {
<li>{{ e.nombre }}</li>
}
</ul>
`,
})
export class BuscadorEstado {
protected readonly texto = signal('');
private readonly normalizado = computed(() => this.texto().trim().toLowerCase());
private readonly busqueda = debounced(this.normalizado, (valor) =>
new Promise<void>((resolver) => setTimeout(resolver, valor.length < 3 ? 600 : 200)),
);
protected readonly buscando = computed(() => this.busqueda.isLoading());
protected readonly encontrados = computed(() => {
const texto = this.busqueda.value();
return EMPLEADOS.filter((e) => e.nombre.toLowerCase().includes(texto));
});
}
Al escribir lu, aparece el mensaje de espera, y tras 600 ms se ve 2 resultados (Luis y Lucía).
Observa que debounced recibe normalizado, no texto: si solo añades un espacio al final, el valor normalizado no cambia y no hay nueva espera.
Versión para la LTS, con conRetardo y un tiempo fijo. Sustituye estas líneas de la clase, y compila en Angular 21.2:
private readonly busqueda = conRetardo(this.normalizado, 300);
protected readonly buscando = computed(() => this.normalizado() !== this.busqueda());
protected readonly encontrados = computed(() => {
const texto = this.busqueda();
return EMPLEADOS.filter((e) => e.nombre.toLowerCase().includes(texto));
});
Ejercicio 11 (propuesto): Presupuesto de departamentos
Añade a Departamento un campo presupuesto. Crea un panel donde se elija un departamento y se pueda aumentar o reducir, de forma inmutable, el salario de sus empleados. Con computed, muestra el gasto anual, el porcentaje de presupuesto usado y un aviso cuando supere el 90 %. Comprueba con un console.log dentro de cada computed que al cambiar de departamento no se recalcula nada innecesario.
Ejercicio 12 (propuesto): Formulario con borrador
Muestra la ficha de un empleado elegido en una lista. Con linkedSignal, crea un borrador editable del salario que se reinicie al cambiar de empleado. Añade Guardar, que escribe el borrador en la lista de forma inmutable, y Descartar, que vuelve al valor original. Crea un computed hayCambios que active el botón Guardar solo cuando el borrador difiera del valor guardado.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Señal | signal(valor) | Valor que avisa a quien lo lee. Se lee con () |
| Escritura | set, update | update calcula a partir del valor actual |
| Solo lectura | asReadonly() | Protege la señal, no el contenido |
| Derivado | computed(fn) | Perezoso y memorizado, con dependencias dinámicas |
| Grafo reactivo | Productores y consumidores | Se construye solo, al leer las señales |
| Ciclo de lectura | Push-pull | Al escribir se avisa; al leer se calcula |
| Igualdad | Object.is, opción equal | Si son iguales no se avisa, y el valor nuevo se descarta |
| Inmutabilidad | update con copias | Mutar no notifica y deja el grafo incoherente |
| Efecto | effect(fn) | Solo para actuar fuera de las señales. Se ejecuta en lotes |
| Limpieza | onCleanup | Se ejecuta antes de repetir y al destruir |
| Destrucción | EffectRef.destroy(), manualCleanup | Normalmente se destruye con su propietario |
| Contexto | Opción injector | Fuera del constructor da NG0203 |
| Sin rastreo | untracked | Lee sin crear una dependencia |
| Estado enlazado | linkedSignal | Escribible, y se reinicia al cambiar sus dependencias |
| Valor anterior | source, computation(nuevo, previo) | Permite conservar la selección |
| Escritura redirigida | Opción set de linkedSignal 🆕 v22.1 | Escribe en la fuente de la verdad |
| Retardo | debounced(fuente, espera) 🆕 v22.0 | Experimental. Devuelve un Resource |
| Depuración | debugName, assertNotInReactiveContext | Nombres para DevTools y comprobación de contexto |