📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~7 min de lecture

outputFromObservable et outputToObservable : les 2 ponts que ta migration EventEmitteroutput() ignore

Tu migres ton composant. @Output() saved = new EventEmitter<User>() devient saved = output<User>(). Le lint est content côté enfant, le template ne bouge pas. Tu lances le build. Ça pète. Property 'pipe' does not exist on type 'OutputRef<User>'. Le parent qui consommait cet output avec .pipe(debounceTime(300)) ne compile plus, et tu réalises que ton EventEmitter héritait de Subject alors que output() non. Ou l'inverse : côté enfant tu as un Subject interne qui pilote la logique de sauvegarde, et tu ne peux plus le brancher directement sur ton output() parce que output() n'a pas de .next().

C'est le trou de la migration EventEmitteroutput() que personne ne documente vraiment. Les deux APIs de secours existent pourtant depuis Angular 17.3 : outputFromObservable() et outputToObservable(), dans @angular/core/rxjs-interop. Elles règlent 100% des cas de mixage RxJS/signals sur les outputs, à condition de comprendre ce qu'elles font vraiment. Voici les 5 pièges qui reviennent, avec le fix pour chacun.

Introduites en developer preview dans Angular 17.3, stables depuis Angular 19. Rien de spécifique à v22 dans les comportements décrits ci-dessous.


TL;DR

Piège Symptôme Fix
1. .pipe() direct sur un output() Compilation KO ou signature unknown outputToObservable(output) puis .pipe()
2. outputFromObservable(source$) avec un source qui complete() Ton output se coupe pour toute la vie du composant takeUntilDestroyed() + source qui ne complète pas
3. outputFromObservable() hors contexte d'injection Error: NG0203 inject() must be called from an injection context Appel au niveau des champs de classe, ou runInInjectionContext
4. Emit synchrone dans le constructeur du composant enfant Le parent rate la première valeur Emit dans un effect() ou après afterNextRender()
5. Test qui subscribe() sur outputToObservable() sans cleanup Leak entre tests (cleanup implicite, pas garanti) TestBed.runInInjectionContext + takeUntilDestroyed()

Règle : output() n'est pas un Subject. C'est une source unidirectionnelle qui vit dans le contexte d'injection du composant. Le pont RxJS existe, mais il te laisse la responsabilité du cycle de vie côté stream.


Rappel de 30 secondes : ce qu'est vraiment output()

Avant les pièges, le modèle mental. output<T>() renvoie un OutputEmitter<T>, qui expose :

  • emit(value) : pousser une valeur vers les listeners du template (myOutput)="...".
  • subscribe(listener) : enregistrer un listener impératif, uniquement pour la mécanique interne du framework.

C'est tout. Pas de .pipe(), pas de .next(), pas d'API RxJS. Un EventEmitter héritait de Subject et te laissait passer, ce qui a rendu tout le monde paresseux pendant 10 ans. output() coupe le lien de parenté volontairement, parce que 95% des devs n'avaient pas besoin de RxJS sur cette API et payaient quand même le coût.

Les deux fonctions d'interop font exactement ce que leur nom promet :

  • outputToObservable(myOutput) renvoie un Observable<T> qui émet à chaque emit(), et complète quand le composant qui contient l'output est détruit.
  • outputFromObservable(source$) crée un OutputEmitter<T> qui emit() à chaque valeur du source$, et cleanup la subscription quand le contexte d'injection est détruit.

Seule outputFromObservable() doit être appelée dans un contexte d'injection valide, comme toSignal et toObservable : elle fait inject(DestroyRef) en interne et lève une erreur sinon. outputToObservable() n'a pas cette contrainte : elle réutilise le DestroyRef déjà rattaché à la ref qu'on lui passe (celui capturé au moment où l'output() a été déclaré), donc elle peut être appelée n'importe où, y compris hors contexte d'injection — c'est justement ce que fait le correctif du piège 1 ci-dessous.


Piège 1 : .pipe() direct sur un output(), côté parent

C'est le premier mur que tu prends en migration. Le parent qui consommait ton EventEmitter fait ça :

Ce qui pique

import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
import { debounceTime, Subscription } from 'rxjs';
import { Editor } from './editor';

@Component({
  selector: 'app-page',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [Editor],
  template: `<app-editor />`,
})
export class Page {
  private readonly editor = viewChild.required(Editor);
  private sub?: Subscription;

  ngAfterViewInit(): void {
    this.sub = this.editor().saved.pipe(debounceTime(300)).subscribe((user) => {
      this.persist(user);
    });
  }

  private persist(user: unknown): void {}
}

Le EventEmitter était un Subject, .pipe() marchait. Depuis que saved est output<User>(), TypeScript refuse : Property 'pipe' does not exist on type 'OutputRef<User>'. Tu pourrais faire editor().saved.subscribe(...) et perdre le debounce, mais c'est un downgrade.

Le correctif

import { ChangeDetectionStrategy, Component, DestroyRef, inject, viewChild } from '@angular/core';
import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs';
import { Editor } from './editor';

@Component({
  selector: 'app-page',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [Editor],
  template: `<app-editor />`,
})
export class Page {
  private readonly editor = viewChild.required(Editor);
  private readonly destroyRef = inject(DestroyRef);

  ngAfterViewInit(): void {
    outputToObservable(this.editor().saved)
      .pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef))
      .subscribe((user) => this.persist(user));
  }

  private persist(user: unknown): void {}
}

outputToObservable() produit un vrai Observable, complet avec .pipe(). Le takeUntilDestroyed(this.destroyRef) explicite est nécessaire parce que tu es dans ngAfterViewInit, donc hors du contexte d'injection courant du composant. Sans lui, le takeUntilDestroyed() par défaut jette NG0203.

Bonus : si tu peux consommer le stream directement dans un champ (en readonly stream$ = outputToObservable(...)), tu n'as pas besoin du DestroyRef explicite. Mais côté viewChild, tu es toujours en runtime, jamais en construction.


Piège 2 : outputFromObservable(source$) avec un source qui complète

Tu construis un composant enfant qui expose un saved mais dont la source vient d'un HttpClient + switchMap :

Ce qui pique

import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { switchMap } from 'rxjs';
import { User } from './user';

@Component({
  selector: 'app-editor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button (click)="submit()">Save</button>`,
})
export class Editor {
  private readonly http = inject(HttpClient);
  readonly draft = input.required<User>();

  private readonly save$ = this.http.put<User>('/api/users', this.draft());
  readonly saved = outputFromObservable(this.save$);

  protected submit(): void {}
}

Ce code est cassé sur trois plans, mais celui qui nous intéresse ici : HttpClient.put() renvoie un Observable qui émet une seule valeur puis complète. outputFromObservable() respecte le complete() de la source : dès que la source complète, l'output s'éteint pour toujours. La deuxième sauvegarde ne sortira jamais.

Le correctif

Deux options selon ton design :

Option A, passer par un Subject interne, si tu veux effectivement piloter les émissions à la main :

import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { Subject, switchMap } from 'rxjs';
import { User } from './user';

@Component({
  selector: 'app-editor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button (click)="submit()">Save</button>`,
})
export class Editor {
  private readonly http = inject(HttpClient);
  readonly draft = input.required<User>();

  private readonly submit$ = new Subject<void>();
  private readonly saved$ = this.submit$.pipe(
    switchMap(() => this.http.put<User>('/api/users', this.draft())),
  );
  readonly saved = outputFromObservable(this.saved$);

  protected submit(): void {
    this.submit$.next();
  }
}

Le Subject ne complète jamais tant que le composant vit. switchMap recycle les completions du put() mais ne les propage pas au subject amont. L'output reste chaud.

Option B, rester impératif avec emit() classique, si aucune logique RxJS ne justifie le pont :

readonly saved = output<User>();

protected async submit(): Promise<void> {
  const saved = await firstValueFrom(this.http.put<User>('/api/users', this.draft()));
  this.saved.emit(saved);
}

Règle : n'utilise outputFromObservable() que si le stream source ne complète pas dans le cycle de vie du composant. Sinon tu payes une abstraction qui te coupe le pied.


Piège 3 : outputFromObservable() hors contexte d'injection

Corollaire du piège précédent. Tu déplaces la création de l'output dans une méthode :

Ce qui pique

export class Editor {
  readonly draft = input.required<User>();
  saved!: OutputEmitter<User>;

  ngOnInit(): void {
    const source$ = someObservable();
    this.saved = outputFromObservable(source$);
  }
}

Runtime error : NG0203: outputFromObservable() can only be used within an injection context. La fonction utilise inject(DestroyRef) sous le capot pour cleanup la subscription. Dans ngOnInit, tu es en dehors du contexte de construction.

En plus, tu déclares un output après la création du composant : l'injecteur du template a déjà résolu ses bindings, (saved)="..." côté parent n'est jamais wire.

Le correctif

output() et outputFromObservable() s'appellent au niveau du champ de classe, jamais dans un hook. C'est la même règle que pour input(), model(), viewChild(). Si tu as besoin d'un injecteur plus tard (rare), fais transiter la logique par un runInInjectionContext(injector, () => ...) explicite, mais c'est un code smell à 99%.

export class Editor {
  readonly draft = input.required<User>();
  private readonly submit$ = new Subject<User>();
  readonly saved = outputFromObservable(this.submit$);
}

C'est déclaratif, l'injecteur est celui de la construction du composant, et le DestroyRef correspond à sa destruction.


Piège 4 : emit synchrone dans le constructeur, parent qui rate la valeur

Celui-là est plus subtil. Tu veux notifier le parent d'un état initial du composant enfant :

Ce qui pique

@Component({
  selector: 'app-badge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ label() }}`,
})
export class Badge {
  readonly label = input.required<string>();
  readonly ready = output<void>();

  constructor() {
    this.ready.emit();
  }
}

Le parent lie (ready)="onReady()". Il ne recevra jamais l'événement. Pourquoi ? Les bindings de sortie sont wire après la construction du composant enfant, dans la phase de création de la vue. Ton emit() part dans le vide.

C'est encore plus vrai avec outputFromObservable() : si ta source émet une valeur synchrone au subscribe (BehaviorSubject, startWith(...), of(...)), elle passe à travers avant que le parent ait câblé quoi que ce soit.

Le correctif

Retarde l'émission d'un tick, avec afterNextRender() ou un effect() :

import { ChangeDetectionStrategy, Component, afterNextRender, input, output } from '@angular/core';

@Component({
  selector: 'app-badge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ label() }}`,
})
export class Badge {
  readonly label = input.required<string>();
  readonly ready = output<void>();

  constructor() {
    afterNextRender(() => this.ready.emit());
  }
}

Ou côté RxJS, décale la première valeur :

readonly state$ = new BehaviorSubject<'idle'>('idle');
readonly stateChange = outputFromObservable(this.state$.pipe(delay(0)));

Le delay(0) déplace l'émission synchrone sur la macrotask suivante (asyncScheduler planifie via setTimeout, pas via une microtask/Promise), ce qui laisse le temps au parent de wire ses listeners.

Règle : les outputs ne sont jamais un canal fiable pour un état initial. Pour ça, un input two-way (model()) ou un service partagé est plus honnête.


Piège 5 : tests qui subscribe() sur outputToObservable() sans cleanup

Le mode par défaut à la migration est de vouloir vérifier qu'un output() a émis en le convertissant en Observable :

Ce qui pique

import { TestBed } from '@angular/core/testing';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { Editor } from './editor';

it('emits saved', () => {
  const fixture = TestBed.createComponent(Editor);
  const values: User[] = [];
  outputToObservable(fixture.componentInstance.saved).subscribe((u) => values.push(u));
  fixture.componentInstance.trigger();
  expect(values).toEqual([{ id: '1', name: 'Alice' }]);
});

outputToObservable() ne lève rien ici : elle réutilise le DestroyRef du composant Editor, pas celui du test (cf. rappel plus haut). C'est justement le problème silencieux : la subscription n'a aucun contrat de cleanup lié au test lui-même. Elle ne se termine que si et quand Editor est détruit par le teardown de TestBed entre deux it(). Si ce teardown est désactivé ou juste plus tardif que prévu, la subscription et son values.push(...) survivent au test et polluent le suivant.

Le correctif

Passe par TestBed.runInInjectionContext() avec le EnvironmentInjector du test, et ajoute takeUntilDestroyed() avec un DestroyRef explicite :

import { DestroyRef, EnvironmentInjector, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';

it('emits saved', () => {
  const fixture = TestBed.createComponent(Editor);
  const values: User[] = [];

  TestBed.runInInjectionContext(() => {
    const destroyRef = inject(DestroyRef);
    outputToObservable(fixture.componentInstance.saved)
      .pipe(takeUntilDestroyed(destroyRef))
      .subscribe((u) => values.push(u));
  });

  fixture.componentInstance.trigger();
  fixture.detectChanges();
  expect(values).toEqual([{ id: '1', name: 'Alice' }]);
});

Mieux encore, si tu es sur Angular 20+, utilise outputBinding() pour tester le contrat de sortie sans jamais convertir en Observable :

import { outputBinding } from '@angular/core';

it('emits saved', () => {
  const values: User[] = [];
  const fixture = TestBed.createComponent(Editor, {
    bindings: [outputBinding<User>('saved', (u) => values.push(u))],
  });
  fixture.componentInstance.trigger();
  expect(values).toEqual([{ id: '1', name: 'Alice' }]);
});

C'est plus proche du contrat parent → enfant, et ça ne demande aucun pont RxJS.


Récap actionnable

  1. .pipe() sur un output() : passe par outputToObservable(), ajoute takeUntilDestroyed() explicite si tu es hors champ de classe.
  2. outputFromObservable(source$) avec source qui complète : Subject interne qui ne complète jamais, ou reste sur emit() impératif.
  3. Appel hors contexte d'injection : déclare output() et outputFromObservable() au niveau du champ de classe, jamais dans un hook.
  4. Emit synchrone au constructeur : retarde avec afterNextRender() ou delay(0). Les outputs ne sont pas un canal d'état initial.
  5. Tests qui pont via outputToObservable() : préfère outputBinding() si dispo, sinon TestBed.runInInjectionContext() + takeUntilDestroyed(destroyRef).

Règle finale : output() a été conçu pour couvrir 95% des cas sans RxJS. Les deux fonctions d'interop existent pour les 5% restants, pas pour recréer ton EventEmitter en plus complexe. Si tu te retrouves à mettre outputFromObservable() sur tous tes composants, il y a de fortes chances que ton design colle encore au modèle mental @Output() = Subject. Sors-en, et ces APIs redeviennent ce qu'elles sont : des ponts précis, pas une béquille par défaut.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.