Módulo 15: RxJS y señales

Objetivos del módulo

  • Entender qué problema resuelve cada modelo reactivo y por qué conviven en una aplicación Angular
  • Convertir un Observable en señal con toSignal, y elegir bien entre initialValue, requireSync y el valor undefined
  • Saber qué ocurre con los errores, con el fin del flujo y con la suscripción cuando se usa toSignal
  • Convertir una señal en Observable con toObservable y explicar por qué no emite todos los valores
  • Evitar fugas de memoria con takeUntilDestroyed, y colocarlo en el sitio correcto de la tubería
  • Publicar un flujo como salida de un componente con outputFromObservable, y consumir una salida como flujo con outputToObservable
  • Cargar datos con rxResource cuando ya tienes servicios basados en Observables
  • Construir un buscador con debounce, cancelación de peticiones y estados de carga y error
  • Decidir cuándo usar señales, recursos u Observables
  • Migrar código antiguo de BehaviorSubject, async y takeUntil a señales, paso a paso y sin romper lo que ya funciona
  • Diagnosticar los errores NG0203, NG0601, NG0602, NG0950, NG0953 y NG0991

1. Dos modelos reactivos en la misma aplicación

En el módulo 05 aprendiste las señales, y en el módulo 14, RxJS. Los dos sirven para reaccionar a cambios, y por eso es fácil pensar que uno sustituye al otro. No es así: responden a preguntas distintas.

Una señal contesta a ¿cuánto vale ahora?. Tiene siempre un valor, lo lees cuando quieres y Angular sabe qué partes de la pantalla dependen de ella. Un Observable contesta a ¿qué ha ocurrido, y cuándo?. Es una secuencia de eventos en el tiempo: puede no haber emitido nada todavía, puede fallar, puede terminar, y sus operadores saben esperar, cancelar y combinar.

Piensa en el marcador de un partido y en la retransmisión. El marcador (señal) siempre muestra un resultado: si llegas en el minuto 60, miras y lo sabes. La retransmisión (Observable) es una sucesión de jugadas: si te la pierdes, te la has perdido, y dos goles iguales son dos goles, no uno.

Comparación entre señales y Observables, unidas por toSignal y toObservable, con takeUntilDestroyed para limpiar y outputFromObservable para las salidas

Figura 1. Cada modelo tiene su terreno. El paquete @angular/core/rxjs-interop aporta los puentes entre ambos.

Pregunta Señal Observable
¿Tiene valor antes de que pase nada? Sí, siempre No necesariamente
¿Cómo se lee? x(), de forma síncrona x$.subscribe(...)
¿Hay que limpiar algo? No Sí, si el flujo no termina solo
¿Dos valores iguales seguidos? El segundo no avisa a nadie Son dos emisiones
¿Puede fallar o terminar? No forma parte del modelo Sí: error y complete
¿Sabe de tiempo? No Sí: debounceTime, delay, timer…
¿Sabe cancelar? No Sí: switchMap, takeUntil…
¿Quién la consume bien? La plantilla, computed, effect Otros operadores

Los puentes viven en un paquete propio

Todo lo que ves en este módulo se importa desde una ruta concreta de @angular/core:

import {
  outputFromObservable,
  outputToObservable,
  pendingUntilEvent,
  rxResource,
  takeUntilDestroyed,
  toObservable,
  toSignal,
} from '@angular/core/rxjs-interop';

Es un punto de entrada aparte dentro del mismo paquete: solo entra en tu aplicación lo que importas. Un proyecto creado con ng new ya tiene rxjs entre sus dependencias, así que no hay nada que instalar.

Preparación: el servicio de la API EMPRESA

Los ejemplos usan la API de ejemplo: descarga recursos/empresa-api.zip, descomprímela y arráncala con node server.mjs. Queda en http://localhost:3000/api. Si quieres ver bien los estados de carga, arráncala con RETARDO=800 node server.mjs, que añade esa espera a cada respuesta.

Este servicio se reutiliza en todo el módulo. Devuelve Observables, porque así es HttpClient:

// src/app/api.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, map } from 'rxjs';

export const API = 'http://localhost:3000/api';

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  deptId: number;
  departamento: string;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}

@Injectable({ providedIn: 'root' })
export class EmpleadosApi {
  private readonly http = inject(HttpClient);

  departamentos(): Observable<Departamento[]> {
    return this.http
      .get<Pagina<Departamento>>(`${API}/departamentos`)
      .pipe(map((pagina) => pagina.contenido));
  }

  buscar(q: string): Observable<Empleado[]> {
    return this.http
      .get<Pagina<Empleado>>(`${API}/empleados`, { params: { q, tamano: 6 } })
      .pipe(map((pagina) => pagina.contenido));
  }

  deDepartamento(deptId: number): Observable<Empleado[]> {
    return this.http
      .get<Pagina<Empleado>>(`${API}/departamentos/${deptId}/empleados`)
      .pipe(map((pagina) => pagina.contenido));
  }

  porId(empId: number): Observable<Empleado> {
    return this.http.get<Empleado>(`${API}/empleados/${empId}`);
  }

  pagina(q: string, pagina: number): Observable<Pagina<Empleado>> {
    return this.http.get<Pagina<Empleado>>(`${API}/empleados`, {
      params: { q, pagina, tamano: 4 },
    });
  }
}

No hace falta provideHttpClient() para que funcione, como viste en el módulo 12. Usamos @Injectable en lugar de @Service para que todo el código de este módulo compile igual en la LTS; el módulo 08 compara ambos.


2. toSignal: de Observable a señal

toSignal recibe un Observable y devuelve una señal de solo lectura que siempre contiene el último valor emitido. Es el puente que más vas a usar, porque la plantilla se lleva muy bien con las señales.

Este reloj cabe en un componente:

// src/app/reloj.ts
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';

@Component({
  selector: 'app-reloj',
  template: `<p class="reloj">{{ hora() }}</p>`,
})
export class Reloj {
  private readonly hora$ = interval(1000).pipe(
    map(() => new Date().toLocaleTimeString('es-ES')),
  );

  protected readonly hora = toSignal(this.hora$, { initialValue: '--:--:--' });
}

Fíjate en lo que no aparece: ni subscribe, ni ngOnDestroy, ni | async. Durante el primer segundo se ve --:--:--, porque interval no emite hasta que pasa su primer intervalo. Después, la hora.

Compáralo con lo que habría que escribir a mano para conseguir lo mismo:

export class RelojManual implements OnInit, OnDestroy {
  protected readonly hora = signal('--:--:--');
  private suscripcion?: Subscription;

  ngOnInit() {
    this.suscripcion = interval(1000)
      .pipe(map(() => new Date().toLocaleTimeString('es-ES')))
      .subscribe((hora) => this.hora.set(hora));
  }

  ngOnDestroy() {
    this.suscripcion?.unsubscribe();
  }
}

📘 Concepto: toSignal se suscribe una sola vez, en el momento de llamarlo, guarda cada valor en una señal interna y cancela la suscripción cuando se destruye el componente (o el contexto en el que se creó).

Qué hace por dentro

El código fuente de toSignal es corto y merece la pena entenderlo, porque explica casi todo su comportamiento:

  1. Crea una señal interna con tres estados posibles: sin valor, con valor o con error.
  2. Se suscribe al Observable de inmediato. next guarda el valor; error guarda la excepción.
  3. Registra la cancelación en el DestroyRef del contexto actual.
  4. Devuelve un computed que lee el estado: si hay valor, lo devuelve; si hay error, lo lanza.

Un Observable alimenta mediante subscribe una señal interna con tres estados, leída por un computed; el DestroyRef corta la suscripción

Figura 2. La señal que recibes es un computed sobre un estado interno. Por eso es de solo lectura y por eso los errores saltan al leerla.

El valor antes de la primera emisión

Una señal siempre tiene que devolver algo, pero un Observable puede no haber emitido aún. ¿Qué devuelve entonces? Depende de las opciones, y el tipo de TypeScript lo refleja:

Llamada Tipo resultante Valor hasta la primera emisión
toSignal(o$) Signal<T \| undefined> undefined
toSignal(o$, { initialValue: null }) Signal<T \| null> null
toSignal(o$, { initialValue: x }) Signal<T \| U> x
toSignal(o$, { requireSync: true }) Signal<T> Exige que o$ emita al suscribirse

Sin opciones, undefined significa «aún no hay datos», y eso es útil para distinguir entre cargando y vacío:

// src/app/departamentos.ts
@Component({
  selector: 'app-departamentos',
  template: `
    @if (departamentos(); as lista) {
      <ul>
        @for (d of lista; track d.deptId) {
          <li>{{ d.nombre }} ({{ d.localidad }})</li>
        }
      </ul>
    } @else {
      <p>Cargando departamentos...</p>
    }
  `,
})
export class Departamentos {
  private readonly api = inject(EmpleadosApi);

  protected readonly departamentos = toSignal(this.api.departamentos());
}

Con initialValue: [] la plantilla se simplifica, pero pierdes esa diferencia: la lista vacía puede significar «cargando» o «no hay ninguno». Elige según lo que necesites mostrar.

requireSync: cuando ya sabes que hay valor

Hay Observables que emiten en el mismo instante de la suscripción: un BehaviorSubject, of(...) o cualquier flujo con startWith. Para esos, requireSync: true te libra de undefined en el tipo:

const tema$ = new BehaviorSubject<'claro' | 'oscuro'>('claro');

const tema = toSignal(tema$, { requireSync: true }); // Signal<'claro' | 'oscuro'>

Es una promesa que le haces a Angular, y se comprueba en tiempo de ejecución. Si el Observable no emite de forma síncrona, salta el error:

NG0601: `toSignal()` called with `requireSync` but `Observable` did not emit synchronously.

Una petición HTTP nunca emite de forma síncrona, así que toSignal(http.get(...), { requireSync: true }) siempre falla. En cambio, toSignal(http.get(...).pipe(startWith(...)), { requireSync: true }) sí es válido, y lo usarás en los ejercicios.

Estas son salidas reales de una prueba:

const s = new Subject<number>();
const senal = toSignal(s);

console.log(toSignal(of(1, 2, 3))()); // 3: of emite todo al suscribirse
console.log(senal());                 // undefined
s.next(5);
console.log(senal());                 // 5

Fíjate en el primer caso: of(1, 2, 3) emite tres valores de golpe, y la señal se queda con el último. Una señal no es una lista de eventos.

Errores: se guardan y saltan al leer

Si el Observable falla, toSignal no lanza nada en ese momento. Guarda el error y lo relanza cada vez que alguien lea la señal. Y un Observable que ha fallado ya no emite más, así que la señal queda rota para siempre:

const s = new Subject<number>();
const c = toSignal(s, { initialValue: 0 });

s.next(1);
console.log(c());                        // 1
s.error(new Error('fallo en la API'));
c();                                     // lanza Error: fallo en la API
c();                                     // lanza Error: fallo en la API

Si esa lectura ocurre en una plantilla, la excepción aparece al pintar y el componente deja de funcionar. Un toSignal sobre HTTP sin tratar el error es una bomba de relojería: mientras la API responde, todo va bien.

La solución es que el Observable no falle nunca, convirtiendo el error en un valor. Para eso se usa una unión que describa los estados de la carga:

type Carga<T> =
  | { estado: 'cargando' }
  | { estado: 'ok'; datos: T }
  | { estado: 'error'; mensaje: string };

function conEstado<T>(origen$: Observable<T>): Observable<Carga<T>> {
  return origen$.pipe(
    map((datos): Carga<T> => ({ estado: 'ok', datos })),
    startWith<Carga<T>>({ estado: 'cargando' }),
    catchError((e: { status?: number }) =>
      of<Carga<T>>({ estado: 'error', mensaje: `Error ${e.status ?? 'de red'}` }),
    ),
  );
}

⚠️ Importante: Nunca conviertas en señal un Observable que pueda fallar sin poner antes un catchError. Lo que recibe la plantilla debe ser un dato, también cuando algo ha ido mal.

Fin del flujo

Si el Observable completa, no pasa nada malo: la señal conserva el último valor. Es lo que ocurre con cada petición HTTP, que emite una vez y termina.

const c$ = new Subject<number>();
const c = toSignal(c$, { initialValue: 0 });

c$.next(9);
c$.complete();
console.log(c()); // 9

Comparar valores con equal

Por defecto, toSignal compara con Object.is. Como cada respuesta HTTP crea objetos nuevos, dos respuestas idénticas se consideran distintas y despiertan a todos los computed que dependan de la señal. Con equal decides tú cuándo dos valores son el mismo:

const empleado = toSignal(empleado$, {
  initialValue: { id: 1 },
  equal: (a, b) => a.id === b.id,
});

En una prueba con tres emisiones { id: 1 }, { id: 1 } y { id: 2 }, un computed dependiente se ejecutó solo dos veces: la lectura inicial y el cambio a id: 2.

También existe la opción debugName, un nombre que identifica la señal en Angular DevTools.

Dónde se puede llamar: el contexto de inyección

toSignal necesita un DestroyRef para saber cuándo cancelar, y lo obtiene del contexto de inyección. Por eso se llama al inicializar campos o en el constructor, como inject(). Lo viste en el módulo 08. Si lo llamas en otro sitio, por ejemplo en un método:

NG0203: toSignal() can only be used within an injection context such as a constructor,
a factory function, a field initializer, or a function used with `runInInjectionContext`.

Hay dos salidas legítimas, aunque casi nunca las necesitarás:

// 1. Pasar un inyector guardado previamente
private readonly injector = inject(Injector);

cargar() {
  this.datos = toSignal(this.api.departamentos(), { injector: this.injector });
}

// 2. Renunciar a la limpieza automática: la suscripción dura hasta que el flujo complete
const una = toSignal(of(1), { manualCleanup: true });

Tampoco se puede llamar dentro de un contexto reactivo: un computed, un effect o una plantilla. Angular lo impide con un error muy claro, porque cada ejecución abriría una suscripción nueva:

NG0602: toSignal() cannot be called from within a reactive context. Invoking `toSignal`
causes new subscriptions every time. Consider moving `toSignal` outside of the reactive context...
// MAL: NG0602 al pintar. La plantilla es un contexto reactivo
protected valor() {
  return toSignal(this.datos$, { injector: this.injector });
}

Un manejador de click no es un contexto reactivo, así que toSignal con injector sí funcionaría ahí, pero cada clic abriría otra suscripción más. Si tienes que recargar datos al pulsar un botón, lo que necesitas es un recurso (reload(), sección 6) o un Subject de eventos con switchMap, creado una sola vez.

💡 Consejo: Regla sencilla: toSignal se llama una vez, al declarar el campo. Si te ves llamándolo desde un método, replantea el diseño.

toSignal se suscribe enseguida, async no

Esta diferencia sorprende a quien migra código antiguo. El pipe async se suscribe cuando la plantilla lo evalúa. toSignal se suscribe al crear el componente, se use o no su señal. Estas son las suscripciones medidas con un Observable contador, con la lectura dentro de un @if (false):

@Component({
  imports: [AsyncPipe],
  template: `@if (false) { {{ datos$ | async }} }`,
})
class ConPipe { datos$ = contador$; }             // 0 suscripciones

@Component({
  template: `@if (false) { {{ datos() }} }`,
})
class ConToSignal { datos = toSignal(contador$); } // 1 suscripción

Si contador$ fuera una petición HTTP, la versión con toSignal la lanzaría aunque la pantalla no la mostrara nunca. Cuando la carga depende de algo que puede no ocurrir, usa un recurso (sección 6) con params que devuelvan undefined, o @defer para retrasar todo el componente (módulo 23).

La otra cara: cada toSignal es una suscripción. Con un Observable frío, dos toSignal significan dos ejecuciones:

const uno = toSignal(contador$);
const dos = toSignal(contador$);                  // 2 suscripciones

const compartido$ = contador$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
const tres = toSignal(compartido$);
const cuatro = toSignal(compartido$);             // 1 suscripción

Aunque casi siempre es mejor crear una señal y derivar lo demás con computed.

Un campo normal no se pinta solo 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) el valor por defecto es Eager, y un campo normal modificado en un subscribe se pinta en la siguiente pasada que provoque otra cosa; en la 22, con OnPush por defecto, no se pinta. Si usas la LTS: usa también toSignal o una señal, y no dependas de esa pasada ajena.

Esta es la razón de fondo para usar toSignal. Sin Zone.js, Angular solo repinta si alguien le avisa. Una señal avisa; asignar un campo, no:

@Component({ selector: 'app-campo', template: `<p>{{ valor }}</p>` })
export class Campo {
  valor = 0;

  constructor() {
    timer(100)
      .pipe(takeUntilDestroyed())
      .subscribe(() => (this.valor = 5)); // Angular no se entera
  }
}

@Component({
  imports: [Campo],
  template: `<app-campo /><p>{{ otra() }}</p>`,
})
export class DemoCampo {
  otra = signal(0);

  constructor() {
    timer(400).pipe(takeUntilDestroyed()).subscribe(() => this.otra.set(1));
  }
}

Al cabo de un segundo, en el navegador, esta pantalla muestra:

Versión valor en pantalla otra en pantalla
Angular 22.2 (OnPush por defecto) 0 1
Angular 21.2 (Eager por defecto) 5 1

En la 22, el dato está bien en memoria y la pantalla miente para siempre. En la 21 el valor aparece solo porque otra señal ha provocado una pasada, es decir, por casualidad. Si quitas DemoCampo y dejas Campo solo, en las dos versiones se queda en 0. El detalle completo está en el módulo 09.


3. toObservable: de señal a Observable

toObservable hace el viaje contrario. Se usa cuando un dato vive en una señal, pero quieres aplicarle algo que solo RxJS sabe hacer: esperar, cancelar, reintentar.

El caso típico es una entrada de componente que dispara una petición:

// src/app/empleados-departamento.ts
@Component({
  selector: 'app-empleados-departamento',
  template: `
    <h3>Departamento {{ deptId() }}: {{ empleados().length }} empleados</h3>
    <ul>
      @for (e of empleados(); track e.empId) {
        <li>{{ e.nombre }} {{ e.apellidos }}</li>
      }
    </ul>
  `,
})
export class EmpleadosDepartamento {
  private readonly api = inject(EmpleadosApi);

  readonly deptId = input.required<number>();

  protected readonly empleados = toSignal(
    toObservable(this.deptId).pipe(
      switchMap((id) => this.api.deDepartamento(id)),
    ),
    { initialValue: [] },
  );
}

Con deptId a 10 la cabecera dice Departamento 10: 1 empleados, y al cambiar el padre a 20, Departamento 20: 6 empleados. Si el usuario cambia de departamento mientras la petición anterior sigue en marcha, switchMap la cancela.

toObservable admite un input.required en un campo sin problemas, aunque el valor aún no exista: no lee la señal al declararla, sino más tarde, como verás ahora.

Por qué no emite todos los valores

toObservable se implementa con dos piezas: un effect que lee la señal y un ReplaySubject(1) que reparte el valor. Y un effect no se ejecuta cuando cambia la señal, sino cuando Angular le da turno. Eso tiene tres consecuencias.

Línea temporal: tres set seguidos en la señal producen una sola ejecución del effect y una sola emisión del Observable, con el último valor

Figura 3. El effect solo ve el valor que hay cuando le toca ejecutarse, de modo que los valores intermedios se pierden.

const n = signal(1);
const vistos: number[] = [];

toObservable(n).subscribe((v) => vistos.push(v));
console.log(vistos);  // []         1. La primera emisión no es síncrona

// ...Angular ejecuta los efectos...
console.log(vistos);  // [1]

n.set(2);
n.set(3);
n.set(4);
// ...Angular ejecuta los efectos...
console.log(vistos);  // [1, 4]     2. Los valores intermedios no llegan

n.set(4);
// ...Angular ejecuta los efectos...
console.log(vistos);  // [1, 4]     3. Un valor igual no emite

Estas salidas son reales. Para reproducirlas en una prueba, el ...Angular ejecuta los efectos... se escribe TestBed.tick(), que verás en el módulo 26.

⚠️ Importante: Con toObservable, el valor llega más tarde que el set, y solo llega el último. Es lo correcto para un estado («¿cuánto vale ahora?»), pero no sirve para contar eventos. No cuentes con .subscribe() para saber cuántas veces se ha llamado a set.

Hay un caso curioso. Si la señal cambia y vuelve al valor anterior en el mismo turno, sí se emite ese valor otra vez, aunque sea igual al último emitido:

n.set(5);
n.set(4);
// ...Angular ejecuta los efectos...
console.log(vistos);  // [1, 4, 4]

La señal ha cambiado de versión, así que el effect se ejecuta, y toObservable no compara con lo último que emitió. Si en tu flujo esto importa, añade distinctUntilChanged(). Es el motivo por el que el buscador de la sección 7 lo lleva.

Otros detalles

  • Suscriptores tardíos. Gracias al ReplaySubject(1), quien se suscribe más tarde recibe de inmediato el último valor: un suscriptor nuevo vio [4] sin esperar a ningún turno.
  • Fin. El Observable completa cuando se destruye el inyector en el que se creó. Si el toObservable está en un servicio root, durará toda la aplicación.
  • Contexto. También necesita contexto de inyección. Fuera de él da NG0203; se puede pasar { injector }. Dentro de un contexto reactivo da NG0602.
  • Errores. Si la señal es un computed que lanza una excepción, el Observable emite un error.

Cuidado con leer un input demasiado pronto

toObservable(this.id) es seguro porque no lee en el momento de declararlo. Pero un operador síncrono que lea la señal al inicializar el campo sí falla:

readonly id = input.required<number>();

// MAL: of() emite ya, y map lee el input antes de que el padre lo haya dado
protected readonly v = toSignal(of(1).pipe(map(() => this.id())));
NG0950: Input "id" is required but no value is available yet.

Una señal no es un bus de eventos

Como toObservable existe, es tentador usar una señal para avisar de cosas: «el usuario ha pulsado guardar». Mira qué pasa al repetir el aviso:

const pulsado = signal('guardar');
effect(() => console.log(pulsado()));   // 'guardar'
pulsado.set('guardar');                 // no ocurre nada: el valor es el mismo

const pulsado$ = new Subject<string>();
pulsado$.subscribe((v) => console.log(v));
pulsado$.next('guardar');               // 'guardar'
pulsado$.next('guardar');               // 'guardar'

El segundo clic en «guardar» se perdería con una señal. Los estados son señales y los eventos son Subject. Si algo se puede repetir y cada repetición cuenta, usa un Subject.


4. takeUntilDestroyed: suscripciones que se limpian solas

toSignal cubre el caso en que quieres un dato. Pero muchas veces te suscribes para hacer algo: escribir en el registro, navegar, mostrar un aviso. Ahí sigue haciendo falta subscribe, y con él llega la pregunta de quién lo cancela.

Una suscripción a un flujo que no termina (interval, fromEvent, un Subject de un servicio) sigue viva aunque el componente desaparezca. Cada vez que el usuario entra y sale de la pantalla se acumula una suscripción más, y cada una retiene el componente en memoria. Es la fuga clásica que viste en el módulo 14.

La fórmula antigua requería un Subject y un ngOnDestroy en cada componente:

export class Antiguo implements OnInit, OnDestroy {
  private readonly destruido$ = new Subject<void>();

  ngOnInit() {
    this.notificaciones.avisos$
      .pipe(takeUntil(this.destruido$))
      .subscribe((aviso) => this.mostrar(aviso));
  }

  ngOnDestroy() {
    this.destruido$.next();
    this.destruido$.complete();
  }
}

takeUntilDestroyed hace lo mismo en una línea: es un operador que completa el flujo cuando se destruye el contexto. Tiene dos formas, según dónde se use:

// src/app/panel-avisos.ts
@Component({
  selector: 'app-panel-avisos',
  template: `<p>{{ recibidos() }} avisos, {{ teclas() }} teclas</p>`,
})
export class PanelAvisos implements OnInit {
  private readonly notificaciones = inject(Notificaciones);
  private readonly destroyRef = inject(DestroyRef);

  protected readonly recibidos = signal(0);
  protected readonly teclas = signal(0);

  constructor() {
    // En el constructor hay contexto de inyección: no hace falta argumento
    this.notificaciones.avisos$
      .pipe(takeUntilDestroyed())
      .subscribe(() => this.recibidos.update((n) => n + 1));
  }

  ngOnInit() {
    // Fuera del contexto: se pasa el DestroyRef guardado
    fromEvent<KeyboardEvent>(document, 'keydown')
      .pipe(
        filter((e) => e.key === 'Escape'),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe(() => this.teclas.update((n) => n + 1));
  }
}
Dónde se llama Forma Por qué
Inicializador de campo, constructor takeUntilDestroyed() Hay contexto de inyección
ngOnInit, métodos, manejadores takeUntilDestroyed(this.destroyRef) No hay contexto: hay que pasarle el DestroyRef

Sin argumento y fuera de contexto, el error te lo dice:

NG0203: takeUntilDestroyed() can only be used within an injection context such as a constructor...

El orden importa: al final de la tubería

takeUntilDestroyed completa lo que tiene antes. Lo que venga después no se entera. Esta prueba crea un componente, lo destruye y mira si las suscripciones siguen vivas:

// BIEN: es el último operador
interval(5)
  .pipe(switchMap(() => of(1)), takeUntilDestroyed())
  .subscribe();
// Tras destruir: 0 emisiones más, y se recibe complete

// MAL: switchMap abre un flujo interno que no se corta
of(1)
  .pipe(takeUntilDestroyed(), switchMap(() => interval(5)))
  .subscribe();
// Tras destruir: el interval sigue emitiendo

⚠️ Importante: takeUntilDestroyed va siempre el último en el pipe. Delante de un switchMap, mergeMap o concatMap deja vivo el flujo interno. Las únicas excepciones razonables son operadores como shareReplay que colocas detrás a propósito.

¿Hace falta en HTTP?

Una petición de HttpClient emite y completa, así que no deja fuga permanente. Aun así, si el usuario sale de la pantalla mientras la petición está en marcha, el subscribe se ejecutará sobre un componente destruido. Con takeUntilDestroyed, la petición se cancela. Cuesta una línea, y vale la pena cuando la respuesta provoca efectos, como navegar o abrir un diálogo.

¿Y un subscribe dentro de un effect?

Es un error frecuente, porque cada ejecución del effect abre una suscripción más. En una prueba, dos ejecuciones dejaron dos suscripciones activas:

// MAL: se acumulan suscripciones
effect(() => {
  this.api.buscar(this.q()).subscribe((r) => this.resultado.set(r));
});

Si no te queda otro remedio, cancela con onCleanup. Con esta forma, al pasar de a a b la prueba registró que la suscripción de a se cerró:

effect((onCleanup) => {
  const sub = this.api.buscar(this.q()).subscribe((r) => this.resultado.set(r));
  onCleanup(() => sub.unsubscribe());
});

Pero lo normal es que quieras otra cosa: un toObservable con switchMap, o directamente un recurso.

¿subscribe o toSignal?

Quieres… Usa
Mostrar el dato o derivar otros toSignal
Provocar un efecto externo: navegar, registrar, abrir un diálogo, guardar subscribe con takeUntilDestroyed
Guardar el dato en una señal escribible que también cambia por otras vías subscribe con takeUntilDestroyed, y set

Si tu subscribe solo hace this.x.set(valor) y nada más escribe en x, entonces sobra: es un toSignal.


5. Salidas de componente con Observables

Las salidas de output() no son Observables, aunque se parecen (módulo 06). Dos funciones conectan ambos mundos.

outputFromObservable: un flujo como salida

Imagina una caja de búsqueda reutilizable que avise al padre solo cuando el usuario deja de teclear. La lógica de tiempo es RxJS, y el resultado se publica como salida:

// src/app/caja-busqueda.ts
@Component({
  selector: 'app-caja-busqueda',
  template: `
    <input #caja type="search" [placeholder]="pista()" (input)="texto.set(caja.value)" />
  `,
})
export class CajaBusqueda {
  readonly pista = input('Buscar...');

  protected readonly texto = signal('');

  readonly buscar = outputFromObservable(
    toObservable(this.texto).pipe(
      skip(1),
      map((texto) => texto.trim()),
      debounceTime(300),
      distinctUntilChanged(),
    ),
  );
}

El skip(1) evita que la caja emita su valor inicial ('') al aparecer. Desde fuera, buscar es una salida como cualquier otra:

// src/app/pantalla-empleados.ts
@Component({
  selector: 'app-pantalla-empleados',
  imports: [CajaBusqueda],
  template: `
    <app-caja-busqueda pista="Buscar empleado" (buscar)="q.set($event)" />
    <p>Búsqueda actual: "{{ q() }}"</p>
    <p>Encontrados: {{ resultado.value().length }}</p>
    <ul>
      @for (e of resultado.value(); track e.empId) {
        <li>{{ e.nombre }} {{ e.apellidos }}</li>
      }
    </ul>
  `,
})
export class PantallaEmpleados {
  private readonly api = inject(EmpleadosApi);

  protected readonly q = signal('');

  protected readonly resultado = rxResource({
    params: () => this.q() || undefined,
    stream: ({ params }) => this.api.buscar(params),
    defaultValue: [],
  });
}

El padre no sabe nada de RxJS ni de debounce: recibe un texto y lo guarda en una señal. El rxResource del padre lo explica la sección 6.

Caja de búsqueda con el texto lop, y debajo la búsqueda actual, dos encontrados y la lista con Ana García López y Javier López Navarro

Figura 4. El componente padre solo ve el texto ya estabilizado que le entrega CajaBusqueda.

Las reglas del Observable de origen son estas:

  • Cada next se reenvía a la salida.
  • Los errores no los gestiona Angular: pon un catchError antes, o la salida morirá con el flujo.
  • Cuando el Observable completa, la salida deja de emitir.
  • Angular cancela la suscripción al destruirse el componente.

Si el catchError captura el error y devuelve EMPTY, el flujo termina limpiamente, pero termina: en una prueba, tras el error la salida ya no recibía nada de la fuente. Para que siga viva, el catchError debe ir dentro de un switchMap, como en la sección 7.

outputToObservable: una salida como flujo

Es el camino contrario. Convierte cualquier salida, de output() o de outputFromObservable(), en un Observable. Sirve cuando quieres aplicar operadores a lo que emite un hijo:

export class Padre {
  private readonly hija = viewChild.required(Hija);
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    afterNextRender(() => {
      outputToObservable(this.hija().normal)
        .pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef))
        .subscribe((valor) => this.registrar(valor));
    });
  }
}

La salida del hijo no existe hasta que se ha pintado la vista, y por eso la suscripción va en afterNextRender. El Observable completa cuando se destruye el componente dueño de la salida. Y una suscripción directa a una salida devuelve un objeto con unsubscribe(), pero no es una Subscription de RxJS, de modo que no tiene add ni se puede encadenar.

Dos comprobaciones sobre un componente ya destruido: llamar a emit() no lanza ningún error ni llega a nadie, y suscribirse da este error:

NG0953: Unexpected subscription to destroyed `OutputRef`. The owning directive/component is destroyed.

6. rxResource: un Observable como cargador 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) rxResource existe con la misma forma (params, stream), pero es experimental y su API puede cambiar; en la 22 es estable. Si usas la LTS: el código de esta sección compila y funciona igual; asume el riesgo o usa toSignal con toObservable y switchMap, como en la sección 3.

En el módulo 13 viste resource y httpResource. rxResource es su variante para quien ya tiene un servicio que devuelve Observables: cambias el cargador por una función stream que devuelve un Observable, y obtienes un recurso normal, con value(), status(), isLoading(), error() y reload().

import { rxResource } from '@angular/core/rxjs-interop';

protected readonly empleados = rxResource({
  params: () => (this.q().length < 2 ? undefined : this.q()),
  stream: ({ params }) => this.api.buscar(params),
  defaultValue: [],
});

Si params devuelve undefined, el recurso no carga. Cuando params cambia, cancela la carga anterior y empieza otra, lo mismo que haría un switchMap. Por eso rxResource sustituye a muchos toObservable(...).pipe(switchMap(...)), y además trae gratis los estados de carga y error.

Si el flujo no emite nada

Un recurso necesita un valor o un error. Si el Observable completa sin emitir, por ejemplo por un catchError(() => EMPTY), el recurso pasa a estado error con este mensaje:

NG0991: Resource completed before producing a value.

La misma prueba da el mismo resultado en 21.2. En la 22 los tipos lo documentan como RESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE.

¿httpResource o rxResource?

Situación Elige
Petición GET sencilla, sin servicio previo httpResource
Ya tienes un servicio que devuelve Observables rxResource
Necesitas operadores en la carga (retry, timeout, combinar dos peticiones) rxResource
La carga es una Promise o usa fetch directamente resource

7. El buscador: el caso que justifica todo lo anterior

Un buscador con resultados mientras se teclea tiene cuatro problemas, y cada uno se resuelve con una pieza concreta:

  1. Demasiadas peticiones. Escribir «garcia» son seis teclas. No queremos seis consultas.
  2. Peticiones repetidas. Borrar una letra y volver a escribirla no debe consultar de nuevo.
  3. Respuestas desordenadas. La respuesta a ga puede llegar después de la de gar, y dejar en pantalla un resultado viejo.
  4. Errores. Si una consulta falla, el buscador debe seguir funcionando para la siguiente.

Las señales no saben esperar ni cancelar. RxJS sí. Así que la entrada y la salida son señales, y el tramo central es RxJS:

Cadena del buscador: la señal texto pasa por toObservable a map, debounceTime, distinctUntilChanged y switchMap, y vuelve a la señal estado mediante toSignal

Figura 5. Entrada y salida en el mundo de las señales. En medio, RxJS hace lo que las señales no saben hacer.

El componente completo

// src/app/buscador.ts
import { Component, computed, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
  Observable,
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  startWith,
  switchMap,
} from 'rxjs';
import { Empleado, EmpleadosApi } from './api';

type EstadoBusqueda =
  | { tipo: 'inicial' }
  | { tipo: 'cargando' }
  | { tipo: 'ok'; empleados: Empleado[] }
  | { tipo: 'error'; mensaje: string };

@Component({
  selector: 'app-buscador',
  template: `
    <label>
      Buscar empleado
      <input #caja type="search" placeholder="Nombre, apellidos..." (input)="texto.set(caja.value)" />
    </label>

    @switch (estado().tipo) {
      @case ('inicial') {
        <p class="aviso">Escribe al menos dos letras.</p>
      }
      @case ('cargando') {
        <p class="aviso">Buscando...</p>
      }
      @case ('error') {
        <p class="error">{{ mensajeError() }}</p>
      }
      @case ('ok') {
        <p class="aviso">{{ empleados().length }} resultados</p>
        <ul class="tarjetas">
          @for (e of empleados(); track e.empId) {
            <li>
              <strong>{{ e.nombre }} {{ e.apellidos }}</strong>
              <span>{{ e.oficio }}, {{ e.departamento }}</span>
            </li>
          } @empty {
            <li>Sin coincidencias.</li>
          }
        </ul>
      }
    }
  `,
})
export class Buscador {
  private readonly api = inject(EmpleadosApi);

  protected readonly texto = signal('');

  private readonly estado$ = toObservable(this.texto).pipe(
    map((texto) => texto.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => this.buscar(q)),
  );

  protected readonly estado = toSignal(this.estado$, {
    initialValue: { tipo: 'inicial' },
  });

  protected readonly empleados = computed(() => {
    const estado = this.estado();
    return estado.tipo === 'ok' ? estado.empleados : [];
  });

  protected readonly mensajeError = computed(() => {
    const estado = this.estado();
    return estado.tipo === 'error' ? estado.mensaje : '';
  });

  private buscar(q: string): Observable<EstadoBusqueda> {
    if (q.length < 2) {
      return of({ tipo: 'inicial' });
    }
    return this.api.buscar(q).pipe(
      map((empleados): EstadoBusqueda => ({ tipo: 'ok', empleados })),
      startWith<EstadoBusqueda>({ tipo: 'cargando' }),
      catchError(() =>
        of<EstadoBusqueda>({ tipo: 'error', mensaje: 'No se ha podido consultar la API.' }),
      ),
    );
  }
}

Buscador de empleados con el texto mar: tres resultados en tarjetas, Luis Martínez Ruiz, Marta Sánchez Gil y Hugo Rubio Marín

Figura 6. El buscador en marcha contra la API EMPRESA.

Los estilos de la captura (.aviso, .error, .tarjetas) son CSS corriente y no influyen en la lógica.

Pieza a pieza

  • map(trim) va antes de distinctUntilChanged, para que gar y gar cuenten como lo mismo.
  • debounceTime(300) deja pasar un valor solo cuando llevan 300 ms sin llegar otros.
  • distinctUntilChanged descarta el valor si es igual al anterior. Si escribes gar, añades una c y la borras antes de que acabe la espera, no se repite la consulta.
  • switchMap se suscribe a la búsqueda nueva y cancela la anterior.
  • startWith emite cargando en cuanto arranca cada búsqueda. Va antes de catchError en la tubería, y aun así funciona, porque emite al suscribirse.
  • catchError está dentro del buscar, no en la tubería principal. Esto es decisivo, y se explica enseguida.

Comprobación: cuántas peticiones salen

Para medirlo, este componente escucha el mismo texto con y sin debounce:

// src/app/comparador.ts (constructor)
const texto$ = toObservable(this.texto).pipe(skip(1));

texto$
  .pipe(
    tap((q) => this.sinFiltro.update((l) => [...l, `q=${q}`])),
    mergeMap((q) => this.api.buscar(q)),
    takeUntilDestroyed(),
  )
  .subscribe();

texto$
  .pipe(
    debounceTime(300),
    distinctUntilChanged(),
    tap((q) => this.conFiltro.update((l) => [...l, `q=${q}`])),
    switchMap((q) => this.api.buscar(q)),
    takeUntilDestroyed(),
  )
  .subscribe();

Tecleando «garcia» con unos 110 ms entre pulsaciones, el navegador registró siete peticiones reales: seis de la primera columna y una de la segunda.

Dos columnas: sin debounce, seis peticiones de q=g a q=garcia; con debounce, una sola petición, q=garcia

Figura 7. Seis teclas: seis peticiones sin debounce, una con él. texto$ admite dos suscriptores porque por dentro hay un ReplaySubject.

Por qué switchMap y no mergeMap

debounceTime reduce las peticiones, pero no evita la carrera: si el usuario hace una pausa de 300 ms, teclea otra letra y la primera respuesta es lenta, hay dos peticiones en vuelo. Esta prueba simula una búsqueda ga que tarda 60 ms y otra gar, posterior, que tarda 10 ms:

const buscar = (q: string, ms: number) => of(`resultado de ${q}`).pipe(delay(ms));

t$.pipe(mergeMap(([q, ms]) => buscar(q, ms))).subscribe(/* ... */);
t$.pipe(switchMap(([q, ms]) => buscar(q, ms))).subscribe(/* ... */);

t$.next(['ga', 60]);
t$.next(['gar', 10]);
mergeMap:  ["resultado de gar", "resultado de ga"]
switchMap: ["resultado de gar"]

Con mergeMap, el último resultado que llega, el que se queda en pantalla, es el de ga, aunque el usuario ya está buscando gar. Con switchMap, nunca llega.

Y la cancelación es real, no solo se ignora la respuesta. Forzando un retraso de 1,5 s en la consulta q=lent y cambiando el texto mientras estaba en vuelo, Chromium informó de que esa petición terminó con net::ERR_ABORTED. Al cancelarse la suscripción, HttpClient aborta la llamada de red. En la pestaña Network de DevTools se ve como cancelada.

Por qué el catchError va dentro

Un Observable que recibe un error termina, y con él toda la tubería. Si pusieras el catchError en la tubería principal, detrás del switchMap, verías el mensaje de error, pero el buscador quedaría muerto: las siguientes teclas no harían nada.

Dentro del switchMap, lo que muere es el Observable interno de esa búsqueda. La tubería externa sigue viva para la siguiente tecla:

// MAL: el primer error mata la tubería
toObservable(this.texto).pipe(
  switchMap((q) => this.api.buscar(q)),
  catchError(() => of([])),
);

// BIEN: el error se queda en la búsqueda que ha fallado
toObservable(this.texto).pipe(
  switchMap((q) => this.api.buscar(q).pipe(catchError(() => of([])))),
);

Comprobado en el navegador, haciendo que la API responda 500 a fallo: aparece el mensaje de error, y al escribir después rui el buscador muestra sus dos resultados con normalidad.

Buscador con el texto fallo y el mensaje en rojo No se ha podido consultar la API

Figura 8. Un error se muestra como un estado más, y la tubería sigue viva para la siguiente búsqueda.

Variante con rxResource

Con la sección 6 en la mano, se puede dejar a RxJS solo la parte del tiempo, y dejar la carga al recurso. Sale menos código, porque desaparece la unión de estados:

// src/app/buscador-recurso.ts
@Component({
  selector: 'app-buscador-recurso',
  template: `
    <label>
      Buscar empleado
      <input #caja type="search" (input)="texto.set(caja.value)" />
    </label>

    @if (empleados.error()) {
      <p class="error">No se ha podido consultar la API.</p>
    } @else if (empleados.isLoading()) {
      <p class="aviso">Buscando...</p>
    }
    <ul>
      @for (e of empleados.value(); track e.empId) {
        <li>{{ e.nombre }} {{ e.apellidos }}</li>
      }
    </ul>
  `,
})
export class BuscadorRecurso {
  private readonly api = inject(EmpleadosApi);

  protected readonly texto = signal('');

  private readonly textoEstable = toSignal(
    toObservable(this.texto).pipe(
      map((texto) => texto.trim()),
      debounceTime(300),
      distinctUntilChanged(),
    ),
    { initialValue: '' },
  );

  protected readonly empleados = rxResource({
    params: () => (this.textoEstable().length < 2 ? undefined : this.textoEstable()),
    stream: ({ params }) => this.api.buscar(params),
    defaultValue: [],
  });
}

Se lee muy bien: texto es lo que escribe el usuario, textoEstable lo mismo cuando deja de teclear, y empleados lo que sale de él. Esta es la forma recomendada si puedes usar recursos, y funciona igual en la LTS (con rxResource experimental, sección 6).

Variante sin RxJS: debounced 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21), y en la 22 debounced sigue siendo experimental. Si usas la LTS: estabiliza el texto con toSignal(toObservable(texto).pipe(debounceTime(300), distinctUntilChanged()), { initialValue: '' }), como en la variante con rxResource de arriba.

debounced, de @angular/core, resuelve justo el trozo de RxJS que quedaba. Recibe una función que lee la señal y una espera, y devuelve un recurso cuyo valor sigue a la señal con retraso:

// src/app/buscador-debounced.ts
import { httpResource } from '@angular/common/http';
import { Component, debounced, signal } from '@angular/core';
import { API, Empleado, Pagina } from './api';

@Component({
  selector: 'app-buscador-debounced',
  template: `
    <label>
      Buscar empleado
      <input #caja type="search" (input)="texto.set(caja.value)" />
    </label>

    @if (empleados.isLoading()) {
      <p class="aviso">Buscando...</p>
    }
    @if (empleados.hasValue()) {
      <ul>
        @for (e of empleados.value().contenido; track e.empId) {
          <li>{{ e.nombre }} {{ e.apellidos }}</li>
        }
      </ul>
    }
  `,
})
export class BuscadorDebounced {
  protected readonly texto = signal('');

  private readonly textoEstable = debounced(this.texto, 300);

  protected readonly empleados = httpResource<Pagina<Empleado>>(() => {
    const q = this.textoEstable.value().trim();
    return q.length < 2 ? undefined : { url: `${API}/empleados`, params: { q, tamano: 6 } };
  });
}

Tecleando «garcia», el navegador registró una sola petición: q=garcia. Cero importaciones de rxjs.

Devuelve un Resource, no una señal: lee con .value(). El tercer argumento admite equal e injector, y la espera puede ser una función que devuelva una Promise si necesitas un tiempo variable.

Al ser experimental puede cambiar, así que no lo uses en código que no vayas a poder revisar. El módulo 05 lo trata con más detalle.

Comparación de las variantes

Variante RxJS necesario Estados de carga y error Estado en 22 LTS (21)
toObservable + switchMap + toSignal Mucho Los escribes tú Estable Igual
toObservable + debounceTime + rxResource Poco Gratis Estable rxResource experimental
debounced + httpResource Ninguno Gratis debounced experimental No existe debounced

8. pendingUntilEvent: avisar al servidor de que espere

📘 Concepto: Este operador está en developer preview tanto en la 21 como en la 22. Te interesa solo si usas renderizado en servidor. Lo verás en contexto en el módulo 24.

Con SSR, Angular serializa el HTML cuando la aplicación está estable, es decir, sin tareas pendientes. Si tienes un flujo cuyo resultado debe salir en ese HTML, pendingUntilEvent hace que la aplicación se considere inestable hasta que el flujo emita, complete, falle o se cancele:

datos$ = this.fuente$.pipe(pendingUntilEvent());

En la prueba, ApplicationRef.whenStable() no se resolvía mientras el flujo no emitió, y se resolvió justo después. También admite un Injector como argumento, para usarlo fuera del contexto de inyección.


9. Cuándo usar cada mundo

Después de ver todos los puentes, la pregunta práctica es dónde poner cada cosa. La respuesta general es que la pantalla lee señales, y RxJS se queda detrás, en los sitios donde aporta algo.

Para el estado derivado, computed gana

RxJS tiene un defecto conocido cuando se usa para calcular estado: la dependencia en diamante. Si suma depende de doble y de triple, y estos dos dependen de a, al cambiar a llegan dos actualizaciones separadas, y combineLatest emite una por cada una. Entre medias ha combinado un valor nuevo con uno viejo:

const a$ = new BehaviorSubject(1);
const doble$ = a$.pipe(map((x) => x * 2));
const triple$ = a$.pipe(map((x) => x * 3));
combineLatest([doble$, triple$]).subscribe(([d, t]) => console.log(`${d}+${t}=${d + t}`));
a$.next(2);
2+3=5
4+3=7     <- nunca fue cierto: a nunca hizo que doble fuera 4 y triple 3
4+6=10

Con señales ese valor falso no existe. computed es perezoso: recalcula cuando lo leen, y entonces todo está actualizado.

const a = signal(1);
const doble = computed(() => a() * 2);
const triple = computed(() => a() * 3);
const suma = computed(() => `${doble()}+${triple()}=${doble() + triple()}`);
// Leyendo antes y después de a.set(2): 2+3=5, 4+6=10

Dos diamantes de dependencias: combineLatest produce tres resultados, uno de ellos falso, y computed produce dos

Figura 9. Es el glitch clásico de RxJS. Con señales no se produce, porque nada se recalcula hasta que se lee.

Para el tiempo y los eventos, RxJS gana

Las señales no tienen nada que equivalga a esperar, reintentar con pausas o cancelar. Sin RxJS, cada una de estas cosas son temporizadores a mano y un puñado de banderas:

Necesidad Con RxJS
Esperar a que el usuario deje de teclear debounceTime
Limitar la frecuencia de un evento throttleTime, auditTime
Reintentar con pausa creciente retry({ count, delay })
Cancelar lo anterior switchMap
No lanzar otra hasta que acabe la actual exhaustMap
Mantener el orden de los guardados concatMap
Escuchar WebSockets o eventos del DOM webSocket, fromEvent
Agrupar eventos por lotes bufferTime

La guía

Árbol de decisión con cuatro preguntas que llevan a señales, recursos, Observables o toSignal con takeUntilDestroyed

Figura 10. Recorre las preguntas de arriba abajo y quédate en la primera que contestes con un sí.

Situación Herramienta
Estado local del componente signal
Dato calculado a partir de otros computed
Estado que se reinicia cuando cambia otro linkedSignal
Datos que dependen de un parámetro httpResource, resource, rxResource
Estado compartido entre componentes Servicio con señales (módulo 19)
Evento que se repite y cada vez cuenta Subject
Lógica con tiempo, cancelación u orden Tubería RxJS que acaba en toSignal
Efectos: navegar, registrar, guardar subscribe con takeUntilDestroyed
API de terceros que da Observables (router, formularios reactivos, CDK) toSignal

Hay dos antipatrones fáciles de reconocer:

  • Señal a Observable a señal sin operadores. toSignal(toObservable(x)) no aporta nada: usa x. El viaje de ida y vuelta solo se justifica si en medio hay un operador.
  • Un BehaviorSubject en un componente. Si solo guarda estado y se lee en la plantilla, es una signal con más ceremonia.

Observables que da el propio framework

Angular todavía entrega Observables en varias API. Una línea con toSignal los trae al mundo de las señales.

Router. Los parámetros de ActivatedRoute emiten al suscribirse, de modo que admiten requireSync:

export class FichaEmpleado {
  private readonly ruta = inject(ActivatedRoute);

  protected readonly id = toSignal(
    this.ruta.paramMap.pipe(map((p) => Number(p.get('id')))),
    { requireSync: true },
  );
}

En una prueba, la señal valió 100 en /e/100, y 101 al navegar a /e/101 dentro del mismo componente. Si puedes activar withComponentInputBinding(), el parámetro llega como input y no hace falta nada de esto (módulo 10).

Formularios reactivos. valueChanges no emite el valor actual al suscribirse, solo los cambios siguientes. Sin más, la señal arranca en undefined aunque el control tenga contenido. Añade startWith:

const control = new FormControl('ana', { nonNullable: true });

const mal = toSignal(control.valueChanges);                                 // undefined
const bien = toSignal(control.valueChanges.pipe(startWith(control.value)),
  { requireSync: true });                                                   // 'ana'

Lo trabajarás en el módulo 17. Si usas Signal Forms (módulo 18), el valor ya es una señal.


10. Migrar código existente

Si llegas a un proyecto Angular con unos años, verás Observables por todas partes. No hace falta reescribirlo de golpe, y es mejor no hacerlo. Los puentes existen justo para migrar por tramos.

Tabla de equivalencias

Antes Después
datos$ \| async en plantilla datos = toSignal(datos$) y datos()
Varios \| async del mismo flujo Una señal y varios computed
BehaviorSubject para un estado signal
combineLatest + map para derivar computed
subscribe que solo asigna un campo toSignal
Subject + takeUntil + ngOnDestroy takeUntilDestroyed
route.paramMap.subscribe(...) withComponentInputBinding y input()
switchMap sobre un parámetro para cargar httpResource o rxResource
@Output() EventEmitter output()
@Output() alimentado por un flujo outputFromObservable

Ejemplo: un servicio de sesión

Este es un servicio típico antiguo, con un componente que lo consume:

// Antes
@Injectable({ providedIn: 'root' })
export class SesionAntes {
  private readonly usuarioSubject = new BehaviorSubject<string | null>(null);

  readonly usuario$ = this.usuarioSubject.asObservable();
  readonly conectado$ = this.usuario$.pipe(map((u) => u !== null));

  entrar(nombre: string) {
    this.usuarioSubject.next(nombre);
  }
  salir() {
    this.usuarioSubject.next(null);
  }
}

@Component({
  selector: 'app-cabecera-antes',
  imports: [AsyncPipe],
  template: `
    @if (conectado$ | async) {
      <p>Hola, {{ usuario$ | async }}. Empleados: {{ total }}</p>
    }
  `,
})
export class CabeceraAntes implements OnInit, OnDestroy {
  private readonly sesion = inject(SesionAntes);
  private readonly api = inject(EmpleadosApi);
  private readonly destruido$ = new Subject<void>();

  protected readonly usuario$ = this.sesion.usuario$;
  protected readonly conectado$ = this.sesion.conectado$;
  protected total = 0;

  ngOnInit() {
    this.api
      .buscar('')
      .pipe(takeUntil(this.destruido$))
      .subscribe((lista) => (this.total = lista.length));
  }

  ngOnDestroy() {
    this.destruido$.next();
    this.destruido$.complete();
  }
}

Y así queda tras migrar. El comportamiento en pantalla es idéntico (las dos cabeceras se han comprobado una junto a la otra), con bastantes menos piezas:

// Después
@Injectable({ providedIn: 'root' })
export class Sesion {
  private readonly usuarioInterno = signal<string | null>(null);

  readonly usuario = this.usuarioInterno.asReadonly();
  readonly conectado = computed(() => this.usuario() !== null);

  // Puente temporal para el código que aún espera un Observable
  readonly usuario$ = toObservable(this.usuario);

  entrar(nombre: string) {
    this.usuarioInterno.set(nombre);
  }
  salir() {
    this.usuarioInterno.set(null);
  }
}

@Component({
  selector: 'app-cabecera',
  template: `
    @if (sesion.conectado()) {
      <p>Hola, {{ sesion.usuario() }}. Empleados: {{ total() }}</p>
    }
  `,
})
export class Cabecera {
  private readonly api = inject(EmpleadosApi);

  protected readonly sesion = inject(Sesion);
  protected readonly total = toSignal(this.api.buscar('').pipe(map((l) => l.length)), {
    initialValue: 0,
  });
}

Orden recomendado

  1. Empieza por las hojas. Los componentes de presentación, que no tienen a nadie debajo.
  2. Cambia las plantillas. Sustituye | async por toSignal sin tocar el servicio. Nadie más nota el cambio.
  3. Cambia las suscripciones. Pasa los takeUntil(destruido$) a takeUntilDestroyed y borra los ngOnDestroy vacíos.
  4. Cambia el servicio. Convierte el estado en señales y deja un usuario$ = toObservable(...) para el código que aún no se ha migrado.
  5. Quita el puente cuando nadie use ya usuario$.

Qué cambia al migrar

La traducción no es perfecta. Estas diferencias son las que dan problemas:

Antes Después Consecuencia
BehaviorSubject.next avisa de forma síncrona toObservable avisa en el siguiente turno El código que hace next y lee algo actualizado en la línea siguiente deja de funcionar
Cada next emite Varios set seguidos dan una emisión Si contabas emisiones, cuenta de otra manera
next con el mismo valor emite set con el mismo valor no hace nada Si eso era un evento, usa un Subject
\| async se suscribe al evaluarse toSignal se suscribe al crearse Una carga oculta tras un @if ahora sí se lanza
Un error llega al subscribe Un error salta al leer la señal Pon catchError antes de toSignal

💡 Consejo: Angular trae migraciones automáticas para input(), output() y las consultas de vista (módulo 28), pero ninguna convierte BehaviorSubject en señales: las diferencias de arriba hacen que tenga que decidirlo una persona.


11. Errores frecuentes

Síntoma Causa Solución
NG0203: toSignal() can only be used within an injection context Se llama en un método o en ngOnInit Declárala como campo, o pasa { injector }
NG0203: takeUntilDestroyed() ... Se llama fuera de contexto sin argumento Pasa el DestroyRef: takeUntilDestroyed(this.destroyRef)
NG0601: toSignal() called with requireSync... requireSync con un flujo que no emite al suscribirse Añade startWith, o quita requireSync y usa initialValue
NG0602: toSignal() cannot be called from within a reactive context toSignal o toObservable dentro de computed, effect o plantilla Sácalo a un campo
NG0950: Input "x" is required but no value is available yet Se lee un input.required al inicializar el campo Envuélvelo en toObservable, o léelo más tarde
NG0953: Unexpected subscription to destroyed OutputRef Suscripción a una salida de un componente destruido Suscríbete antes, o comprueba que sigue vivo
NG0991: Resource completed before producing a value El stream de rxResource completa sin emitir Devuelve un valor en lugar de EMPTY
La plantilla muestra un dato viejo Un subscribe asigna un campo normal Usa toSignal o una señal
El buscador deja de responder tras un error catchError fuera del switchMap Muévelo dentro
Se ven resultados de una búsqueda anterior mergeMap en lugar de switchMap Usa switchMap
Una petición sale dos veces Dos toSignal sobre el mismo Observable frío Una sola señal y computed, o shareReplay
El subscribe sigue vivo tras salir de la pantalla takeUntilDestroyed no es el último operador Ponlo al final del pipe
toObservable se salta valores Varios set en el mismo turno Es lo esperado. Para eventos, usa un Subject
Un componente con toSignal falla al pintar sin causa aparente El Observable ha fallado y el error salta al leer Añade catchError y convierte el error en un dato
La señal del formulario empieza en undefined valueChanges no emite el valor inicial Añade startWith(control.value)

12. Ejercicios

✏️ Ejercicio: Usa el servicio EmpleadosApi de la sección 1, con la API EMPRESA en marcha. Todas las soluciones están compiladas y comprobadas en el navegador contra la API.

Ejercicio 1: Cuenta atrás de sesión

Crea CuentaAtras, que muestre «La sesión caduca en N s» durante 3 segundos, y al llegar a 0 cambie a «Sesión caducada». Usa timer y toSignal. El flujo debe terminar solo.

Ver solución
@Component({
  selector: 'app-cuenta-atras',
  template: `
    @if (quedan() > 0) {
      <p>La sesión caduca en {{ quedan() }} s</p>
    } @else {
      <p>Sesión caducada</p>
    }
  `,
})
export class CuentaAtras {
  private readonly total = 3;

  protected readonly quedan = toSignal(
    timer(0, 1000).pipe(
      take(this.total + 1),
      map((n) => this.total - n),
    ),
    { initialValue: this.total },
  );
}

timer(0, 1000) emite 0, 1, 2, 3 a intervalos de un segundo. take(4) hace que termine tras el último, y la señal conserva el valor 0 porque completar no la borra. Si toSignal no se cancelase al destruir el componente, el take lo limitaría igualmente, pero en un flujo infinito esa limpieza sería imprescindible.

Comprobado: al cargar se ve «caduca en 3 s» y a los 3 segundos «Sesión caducada».

Ejercicio 2: Departamentos con estados

Muestra la lista de departamentos con tres estados: «Cargando…», la lista, o un mensaje con el código de error. La señal debe ser Signal<Carga<Departamento[]>>, sin undefined, y sin initialValue. Reutiliza conEstado de la sección 2.

Ver solución
@Component({
  selector: 'app-tabla-departamentos',
  template: `
    @switch (carga().estado) {
      @case ('cargando') {
        <p>Cargando...</p>
      }
      @case ('error') {
        <p class="error">{{ mensaje() }}</p>
      }
      @case ('ok') {
        <ul>
          @for (d of departamentos(); track d.deptId) {
            <li>{{ d.nombre }}</li>
          }
        </ul>
      }
    }
  `,
})
export class TablaDepartamentos {
  private readonly api = inject(EmpleadosApi);

  protected readonly carga = toSignal(conEstado(this.api.departamentos()), {
    requireSync: true,
  });

  protected readonly departamentos = computed<Departamento[]>(() => {
    const c = this.carga();
    return c.estado === 'ok' ? c.datos : [];
  });

  protected readonly mensaje = computed(() => {
    const c = this.carga();
    return c.estado === 'error' ? c.mensaje : '';
  });
}

La clave es requireSync: true. Una petición HTTP no emite de forma síncrona, pero conEstado lleva un startWith, y ese sí. Así la señal tiene valor desde el principio y el tipo no incluye undefined.

Comprobado: muestra los siete departamentos, de DIRECCIÓN a INVESTIGACIÓN.

Ejercicio 3: Ficha de empleado por input

Crea FichaEmpleado, con una entrada id obligatoria, que cargue el empleado cada vez que cambie y muestre carga y error. Si llega un id que no existe, debe verse «Error 404», y el componente debe seguir funcionando para el siguiente id.

Ver solución
@Component({
  selector: 'app-ficha-empleado',
  template: `
    @if (ficha(); as f) {
      @switch (f.estado) {
        @case ('cargando') {
          <p>Cargando ficha...</p>
        }
        @case ('error') {
          <p class="error">{{ f.mensaje }}</p>
        }
        @case ('ok') {
          <p>{{ f.datos.nombre }} {{ f.datos.apellidos }} ({{ f.datos.oficio }})</p>
        }
      }
    }
  `,
})
export class FichaEmpleado {
  private readonly api = inject(EmpleadosApi);

  readonly id = input.required<number>();

  protected readonly ficha = toSignal(
    toObservable(this.id).pipe(switchMap((id) => conEstado(this.api.porId(id)))),
  );
}

conEstado envuelve cada petición dentro del switchMap, así que un 404 no mata la tubería. Aquí no sirve requireSync: el startWith emite de forma síncrona respecto a la petición, pero toObservable no emite hasta que se ejecuta su effect. Por eso el tipo incluye undefined, y @if (ficha(); as f) lo descarta.

@switch sobre f.estado estrecha el tipo en cada @case, de modo que f.datos compila.

Comprobado: con id 100 muestra «Ana García López (DIRECTOR GENERAL)», y con 99999, «Error 404».

Ejercicio 4: Tema con requireSync

Un servicio antiguo guarda el tema en un BehaviorSubject. Sin modificarlo, crea SelectorTema, con un botón que muestre Tema CLARO o Tema OSCURO y lo alterne al pulsar.

type Tema = 'claro' | 'oscuro';

@Injectable({ providedIn: 'root' })
export class TemaServicio {
  private readonly tema = new BehaviorSubject<Tema>('claro');
  readonly tema$ = this.tema.asObservable();
  alternar() {
    this.tema.next(this.tema.value === 'claro' ? 'oscuro' : 'claro');
  }
}
Ver solución
@Component({
  selector: 'app-selector-tema',
  template: `
    <button type="button" (click)="servicio.alternar()">Tema {{ etiqueta() }}</button>
  `,
})
export class SelectorTema {
  protected readonly servicio = inject(TemaServicio);

  private readonly tema = toSignal(this.servicio.tema$, { requireSync: true });

  protected readonly etiqueta = computed(() => this.tema().toUpperCase());
}

Un BehaviorSubject emite su valor al suscribirse, aunque se exponga con asObservable(), de modo que requireSync es correcto y el tipo es Signal<Tema>. Gracias a eso, this.tema().toUpperCase() no necesita comprobar undefined.

Comprobado: pasa de Tema CLARO a Tema OSCURO al pulsar.

Ejercicio 5: Arregla la fuga

Este componente tiene tres fallos: no se limpia, usa un campo que no se pinta, y cuenta con un subscribe. El servicio Notificaciones vive toda la aplicación.

@Component({
  selector: 'app-contador-avisos',
  template: `<p>{{ cuenta }} avisos. Último: {{ ultimo }}</p>`,
})
export class ContadorAvisos implements OnInit {
  private readonly notificaciones = inject(Notificaciones);

  protected ultimo = 'ninguno';
  protected cuenta = 0;

  ngOnInit() {
    this.notificaciones.avisos$.subscribe((aviso) => {
      this.ultimo = aviso;
      this.cuenta++;
    });
  }
}

Corrígelo. Deja cuenta en un subscribe dentro de ngOnInit, para practicar esa forma, y convierte ultimo en toSignal.

Ver solución
@Component({
  selector: 'app-contador-avisos',
  template: `<p>{{ cuenta() }} avisos. Último: {{ ultimo() }}</p>`,
})
export class ContadorAvisos implements OnInit {
  private readonly notificaciones = inject(Notificaciones);
  private readonly destroyRef = inject(DestroyRef);

  protected readonly ultimo = toSignal(this.notificaciones.avisos$, { initialValue: 'ninguno' });
  protected readonly cuenta = signal(0);

  ngOnInit() {
    this.notificaciones.avisos$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.cuenta.update((n) => n + 1));
  }
}

En ngOnInit no hay contexto de inyección, por eso DestroyRef se inyecta en un campo y se pasa como argumento. Otra opción, sin ningún subscribe, es declarar cuenta con toSignal(this.notificaciones.avisos$.pipe(scan((n) => n + 1, 0)), { initialValue: 0 }).

Comprobado: tras publicar un aviso muestra 1 avisos. Último: stock bajo. Al ocultar el componente, publicar otro y mostrarlo de nuevo, muestra 0 avisos. Último: ninguno, porque es una instancia nueva y avisos$ es un Subject que no guarda nada. La instancia vieja ya no recibe avisos.

Ejercicio 6: Caja con mínimo de letras

Modifica CajaBusqueda para añadir la entrada minimo (por defecto 2): la salida solo emite si el texto tiene al menos esa longitud, o si está vacío, para que el padre pueda limpiar los resultados.

Ver solución
@Component({
  selector: 'app-caja-minimo',
  template: `<input #caja type="search" (input)="texto.set(caja.value)" />`,
})
export class CajaMinimo {
  readonly minimo = input(2);

  protected readonly texto = signal('');

  readonly buscar = outputFromObservable(
    toObservable(this.texto).pipe(
      skip(1),
      map((t) => t.trim()),
      filter((t) => t.length === 0 || t.length >= this.minimo()),
      debounceTime(300),
      distinctUntilChanged(),
    ),
  );
}

Leer this.minimo() dentro de filter es seguro porque se ejecuta cuando el usuario teclea, con las entradas ya establecidas. Una función de operador se ejecuta en cada emisión, no al declararse, y por eso usa siempre el valor vigente. En cambio, debounceTime(this.espera()) sí leería el input al declarar el campo: la espera sería la del valor por defecto.

Comprobado con [minimo]="3": ga no emite, garc emite, y al vaciar la caja emite ''.

Ejercicio 7: Autoguardado

Crea NotaDepartamento, con un textarea. Medio segundo después de dejar de escribir, se guarda la nota. Debe mostrar Pendiente mientras hay cambios sin guardar, Guardado al terminar, y el número de guardados. Dos guardados nunca deben solaparse, y ninguno debe perderse. Simula el guardado con of(texto).pipe(delay(200)).

Ver solución
@Component({
  selector: 'app-nota-departamento',
  template: `
    <textarea #caja (input)="nota.set(caja.value)"></textarea>
    <p>{{ estado() }} ({{ guardados() }} guardados)</p>
  `,
})
export class NotaDepartamento {
  protected readonly nota = signal('');
  protected readonly estado = signal('Sin cambios');
  protected readonly guardados = signal(0);

  constructor() {
    toObservable(this.nota)
      .pipe(
        skip(1),
        tap(() => this.estado.set('Pendiente')),
        debounceTime(500),
        distinctUntilChanged(),
        concatMap((texto) => this.guardar(texto)),
        takeUntilDestroyed(),
      )
      .subscribe(() => {
        this.estado.set('Guardado');
        this.guardados.update((n) => n + 1);
      });
  }

  private guardar(texto: string): Observable<string> {
    return of(texto).pipe(delay(200));
  }
}

Aquí se usa subscribe con takeUntilDestroyed porque guardar es un efecto, no un dato para mostrar.

concatMap es la elección correcta: pone en cola y mantiene el orden. Con switchMap, un guardado en curso se cancelaría al llegar otro, y con mergeMap dos guardados podrían solaparse y escribirse en orden inverso. takeUntilDestroyed sigue siendo el último operador.

Comprobado: tras teclear hola muestra Pendiente (0 guardados), y poco después Guardado (1 guardados).

Ejercicio 8: Listado con búsqueda y paginación

Crea ListadoPaginado, con una caja de búsqueda, botones Anterior y Siguiente, y api.pagina(q, pagina). Requisitos: la búsqueda espera 300 ms, cambiar de página no espera, y cambiar el texto vuelve a la página 1.

Ver solución
@Component({
  selector: 'app-listado-paginado',
  template: `
    <input #caja type="search" (input)="texto.set(caja.value)" />
    <button type="button" [disabled]="pagina() === 1" (click)="pagina.update((p) => p - 1)">
      Anterior
    </button>
    <span>Página {{ pagina() }} de {{ resultado.value().paginas }}</span>
    <button
      type="button"
      [disabled]="pagina() >= resultado.value().paginas"
      (click)="pagina.update((p) => p + 1)"
    >
      Siguiente
    </button>
    <ul>
      @for (e of resultado.value().contenido; track e.empId) {
        <li>{{ e.nombre }} {{ e.apellidos }}</li>
      }
    </ul>
  `,
})
export class ListadoPaginado {
  private readonly api = inject(EmpleadosApi);

  protected readonly texto = signal('');

  private readonly q = toSignal(
    toObservable(this.texto).pipe(
      map((t) => t.trim()),
      debounceTime(300),
      distinctUntilChanged(),
    ),
    { initialValue: '' },
  );

  protected readonly pagina = linkedSignal({
    source: this.q,
    computation: () => 1,
  });

  protected readonly resultado = rxResource({
    params: () => ({ q: this.q(), pagina: this.pagina() }),
    stream: ({ params }) => this.api.pagina(params.q, params.pagina),
    defaultValue: { contenido: [] as Empleado[], pagina: 1, tamano: 4, total: 0, paginas: 1 },
  });
}

Cada pieza hace lo suyo: RxJS se encarga del tiempo, linkedSignal se reinicia cuando cambia q, y el recurso carga. Con solo señales y un recurso, sin combineLatest, el orden es automático: q y pagina cambian en el mismo turno, y params se calcula una vez con los dos valores nuevos. Es la dependencia en diamante de la sección 9, y aquí no hay valor falso.

rxResource es experimental en la LTS, y todo lo demás es igual allí. Lo hemos ejecutado también en la 21.2.

Comprobado: sin texto, Página 1 de 6; al pasar a la página 2 cambia la lista; y al escribir ma vuelve a Página 1 de 2.

Ejercicio 9: Migra un servicio manteniendo la compatibilidad

Este servicio lo usan muchos componentes por total$. Pásalo a señales sin romperlos.

@Injectable({ providedIn: 'root' })
export class CarritoAntes {
  private readonly lineas = new BehaviorSubject<LineaPedido[]>([]);

  readonly total$ = this.lineas.pipe(
    map((lineas) => lineas.reduce((s, l) => s + l.precio * l.cantidad, 0)),
  );

  anadir(linea: LineaPedido) {
    this.lineas.next([...this.lineas.value, linea]);
  }
}
Ver solución
export interface LineaPedido {
  producto: string;
  precio: number;
  cantidad: number;
}

@Injectable({ providedIn: 'root' })
export class Carrito {
  private readonly lineasInternas = signal<LineaPedido[]>([]);

  readonly lineas = this.lineasInternas.asReadonly();
  readonly total = computed(() =>
    this.lineas().reduce((suma, l) => suma + l.precio * l.cantidad, 0),
  );

  // Compatibilidad con el código antiguo
  readonly total$ = toObservable(this.total);

  anadir(linea: LineaPedido) {
    this.lineasInternas.update((lineas) => [...lineas, linea]);
  }
}

El código nuevo lee carrito.total(), y el antiguo sigue suscrito a total$ sin saber que por debajo hay señales. update crea un array nuevo, y eso es necesario: una mutación con push no cambiaría la referencia, de modo que nadie se enteraría.

Queda una diferencia: total$ ahora emite más tarde, y dos anadir seguidos en la misma función dan una emisión. En cambio, con el BehaviorSubject daban dos. Un componente viejo no debería notarlo, pero revísalo.

Comprobado: un suscriptor a total$ recibió 0, 60, 120 al añadir dos líneas de 60 con un clic entre ambas, y carrito.total() valía 120.

Ejercicio 10 (propuesto): Estado de la conexión

Crea Conexion, que muestre «En línea» o «Sin conexión». Combina fromEvent(window, 'online') y fromEvent(window, 'offline') con merge, añade startWith(navigator.onLine) y usa toSignal con requireSync. Pruébalo con el modo Offline de DevTools.

Ejercicio 11 (propuesto): Buscador sin RxJS

Parte de Buscador (sección 7) y reescríbelo con debounced y httpResource. Compara las líneas de código y qué estados te da gratis. Después, escribe en un comentario qué harías si el proyecto tuviera que seguir en la LTS.

Ejercicio 12 (propuesto): Reintentos con pausa

Añade a EmpleadosApi una versión de buscar que reintente dos veces con retry({ count: 2, delay: 1000 }). Úsala en BuscadorRecurso y provoca un error parando la API. ¿Cuánto tarda en aparecer el mensaje? ¿Podrías hacer esto con httpResource? Razona por qué aquí conviene rxResource.


Resumen

Concepto API Idea clave
De Observable a señal toSignal(o$) Se suscribe al crearse, y cancela al destruirse. Una vez, como campo
Valor inicial initialValue, requireSync Sin ellos, el tipo incluye undefined
Error en toSignal catchError antes El error salta al leer, y la señal queda rota
De señal a Observable toObservable(s) Usa un effect: es asíncrono y solo emite el último valor
Estado frente a evento signal frente a Subject Si cada repetición cuenta, es un Subject
Limpieza de suscripciones takeUntilDestroyed() Va la última en el pipe
Limpieza fuera de contexto takeUntilDestroyed(destroyRef) Guarda el DestroyRef en un campo
Flujo como salida outputFromObservable(o$) Angular no trata sus errores
Salida como flujo outputToObservable(out) Completa al destruirse el componente
Carga con Observable rxResource({ params, stream }) 🔄 v22.0: estable; experimental en la LTS
SSR con flujos pendingUntilEvent() Developer preview en 21 y 22
Espera en un buscador debounceTime, distinctUntilChanged map(trim) va delante
Carreras switchMap Cancela de verdad la petición anterior
Error en la tubería catchError dentro del switchMap Fuera, el buscador muere
Debounce con señales debounced(fuente, ms) 🆕 v22.0, experimental. Devuelve un Resource
Estado derivado computed Sin el glitch de combineLatest
Regla general La pantalla lee señales RxJS se queda detrás, para tiempo y eventos
Migración De hojas a servicios Deja un toObservable de puente mientras dure