
Una mejor manera de cancelar suscripciones en Angular
En Angular, uno de los aspectos más críticos al trabajar con observables es suscribirse y desuscribirse adecuadamente para evitar fugas de memoria. En versiones anteriores, esto requería el uso de métodos adicionales que generaban mucho código redundante, pero con la llegada de Angular v16, se introdujo el nuevo operador takeUntilDestroyed() que simplifica considerablemente este proceso.
¿Para qué sirve takeUntilDestroyed()?
El operador takeUntilDestroyed() se utiliza para gestionar las suscripciones a observables y asegura que estas se cancelen automáticamente cuando el componente que las genera se destruye. Esto permite a los desarrolladores reducir el boilerplate del código y mejorar la legibilidad, además de prevenir problemas de fugas de memoria.
¿Cómo lo hacíamos antes?
Antes de la llegada de takeUntilDestroyed(), la estrategia común era usar el operador takeUntil() en combinación con un Subject. Esto implicaba definir un objeto Subject (por ejemplo, llamado destroyed) y emitir un valor en el ngOnDestroy para cancelar la suscripción.
Un ejemplo de esto sería:
export class TakeUntilComponent implements OnDestroy {
private todosService = inject(JsonPlaceholderService);
private destroyed = new Subject<void>();
todos: Todo[] = [];
ngOnInit(): void {
this.todosService
.getTodos()
.pipe(takeUntil(this.destroyed))
.subscribe((response) => (this.todos = response));
}
ngOnDestroy(): void {
this.destroyed.next();
this.destroyed.complete();
}
}
Aunque funcional, este patrón genera bastante código repetitivo.
Implementación de takeUntilDestroyed()
La nueva forma de utilizar takeUntilDestroyed() es mucho más sencilla. Solo es necesario agregarlo a la cadena de operadores, eliminando la necesidad de manejar la lógica de destrucción manual. Aquí tienes un ejemplo:
export class TakeUntilComponent {
private todosService = inject(JsonPlaceholderService);
todos: Todo[] = [];
constructor() {
this.todosService
.getTodos()
.pipe(takeUntilDestroyed())
.subscribe((response) => (this.todos = response));
}
}
Atención: el contexto de inyección importa
Aunque hay que tener cuidado dónde usemos este operador, ya que si lo hacemos fuera del injection context nos arrojará un error:
ERROR RuntimeError: NG0203: takeUntilDestroyed()
can only be used within an injection context such as a constructor,
a factory function, a field initializer, or a function used with
runInInjectionContext.
Find more at https://angular.dev/errors/NG0203
Para solucionar esto, Angular provee el DestroyRef, que se puede inyectar para usar el operador en otros métodos, como ngOnInit:
export class TakeUntilComponent implements OnDestroy {
private todosService = inject(JsonPlaceholderService);
private destroyRef = inject(DestroyRef);
todos: Todo[] = [];
ngOnInit(): void {
this.todosService
.getTodos()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((response) => (this.todos = response));
}
}
Ahorremos más código repetitivo
Podemos incluso eliminar la necesidad de crear un destroyRef en cada component si creamos una función de utilidad que haga esto por nosotros.
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export const getTakeUntilDestroyed = () => {
const destroyRef = inject(DestroyRef);
return <T>() => takeUntilDestroyed<T>(destroyRef);
};
Y posteriormente usarlo de la siguiente manera en nuestro componente:
export class TakeUntilDestroyedComponent {
private todosService = inject(JsonPlaceholderService);
todos: Todo[] = [];
takeUntilDestroyed = getTakeUntilDestroyed();
ngOnInit(): void {
this.todosService
.getTodos()
.pipe(this.takeUntilDestroyed())
.subscribe((response) => (this.todos = response));
}
}
Flexibilidad en contextos complejos
Una de las ventajas del nuevo takeUntilDestroyed() es su flexibilidad. Permite aplicar la referencia de destrucción en contextos más complejos, como en componentes padre e hijo. Por ejemplo, si quieres que una suscripción en un componente padre se mantenga activa mientras el componente hijo exista, puedes inyectar DestroyRef en el hijo y pasárselo al padre:
export class Parent {
@ViewChild(Child) child!: Child;
ngOnInit(): void {
interval(1000)
.pipe(takeUntilDestroyed(this.child.destroyRef))
.subscribe(count => console.log(count));
}
}
Esto permite un manejo más granular de las suscripciones y su duración.
No es necesario usarlo siempre
Ten cuidado al hacer operaciones como actualizar (PUT) o borrar (DRLETE) ya que si se cancela la suscripción este proceso no se llevará a cabo y podrías estar dando una mal experiencia a tus usuarios.
El order de operación importa
Al encadenar operadores en un pipe, su orden afecta el comportamiento. Imagina que tienes un catchError y takeUntilDestroyed():
// ❌ Peligroso: Si hay un error, takeUntilDestroyed() podría cancelar antes de manejar el error.
.pipe(
takeUntilDestroyed(),
catchError(err => /* ... */)
)
// ✅ Correcto: Maneja el error primero, luego cancela.
.pipe(
catchError(err => /* ... */),
takeUntilDestroyed()
)
Para esto puedes implementar reglas del paquete rxjs-tslint-rules como rxjs-no-unsafe-takeuntil la cual prohibe que existan operadores después del takeUntil o takeUntilDestroyed.
Conclusión
El operador takeUntilDestroyed() es una poderosa incorporación al arsenal de Angular que no solo disminuye el boilerplate de código, sino que también incrementa la legibilidad y la seguridad en gestión de observables. Con su implementación, los desarrolladores pueden centrarse más en la lógica de negocio en lugar de preocuparse por los detalles de gestión de suscripciones.
Mira el código completo en StackBlitz.
Suscríbete al canal de Youtube de Pacificode.