📧 Reste informé(e) !

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

S'inscrire gratuitement

~8 min de lecture

toSignal() et toObservable() : 6 pièges d'interop qui font crasher ton state Angular

Tu as commencé à passer ton code Angular en Signals, mais tu as encore 30% de ton state en RxJS : un HttpClient ici, un BehaviorSubject partagé là, un formGroup.valueChanges ailleurs. Tu fais le pont avec toSignal() et toObservable(). Et un coup sur deux, ton composant affiche undefined une frame, avale une valeur initiale, ou throw au runtime.

Le problème : ces deux fonctions ont l'air triviales, mais elles compressent un changement de modèle de réactivité dans deux lignes d'API. Pull vs push, sync vs async, lazy vs eager, scoped vs persistant. À chaque appel, tu fais un choix implicite qui peut casser ton state sans message d'erreur explicite.

Voici 6 pièges qui te coûtent des heures de debug, avec le pattern qui les corrige. Valide Angular 17+ (les APIs sont en @angular/core/rxjs-interop, stables depuis la v17.2).


TL;DR

Piège Symptôme Fix
1. toSignal() sans initialValue type devient T | undefined, frame vide au render passer initialValue ou requireSync: true
2. requireSync: true sur un observable async Error: NG0601 au runtime n'utiliser que sur observables qui émettent sync
3. toObservable(signal) coalesce les writes sync l'abonné rate des valeurs intermédiaires écrire en async, ou passer par effect()
4. toSignal() hors injection context Error: NG0203 inject() must be called passer injector explicitement
5. toObservable() lu de façon synchrone undefined au subscribe, la valeur arrive au tick suivant lire le signal directement ; émission asynchrone assumée
6. manualCleanup: true oublié leak silencieux sur composants longue durée laisser le default DestroyRef faire son job

Setup : ce que font vraiment ces deux fonctions

Avant les pièges, le modèle mental :

  • toSignal(observable) souscrit à l'observable, stocke la dernière valeur dans un signal, et se désabonne quand le DestroyRef du contexte d'injection courant se déclenche. C'est eager (souscription immédiate) et stateful (mémorise la dernière valeur).
  • toObservable(signal) crée un seul effect() (eager, dès l'appel) qui pousse chaque valeur du signal dans un ReplaySubject(1) interne. C'est multicast (un effect partagé par tous les subscribers, pas un par abonné), glitch-free (coalesce les writes synchrones) et asynchrone (la première valeur arrive après le microtask courant, jamais de façon synchrone au subscribe).

Garde ces deux propriétés en tête, la moitié des pièges vient de là.


Piège 1 : toSignal() sans initialValue te file un type qui inclut undefined

Le cas qui revient le plus souvent. Tu écris :

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

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h2>{{ user()?.name }}</h2>`,
})
export class UserCard {
  private http = inject(HttpClient);

  user = toSignal(this.http.get<User>('/api/user'));
}

Le type de user est Signal<User | undefined>. Tu te tapes des ?. partout dans le template, et au premier render le composant affiche un blanc le temps que l'HTTP réponde.

Trois fixes selon l'intention :

// 1. Tu veux un placeholder pour le render initial
user = toSignal(this.http.get<User>('/api/user'), {
  initialValue: { id: '', name: 'Loading...' } as User,
});

// 2. Tu sais que l'observable est synchrone (BehaviorSubject, of(), signal-driven)
user = toSignal(this.userStore.user$, { requireSync: true });

// 3. Tu veux gérer l'état de chargement explicitement
state = toSignal(
  this.http.get<User>('/api/user').pipe(
    map(user => ({ status: 'ready' as const, user })),
    startWith({ status: 'loading' as const }),
  ),
  { requireSync: true },
);

Le pattern 3 est mon préféré quand tu fais du HTTP : tu modélises l'état (loading | ready | error) dans le pipe RxJS et tu sors un signal jamais undefined côté composant. Le template traite chaque cas avec @switch (state().status).


Piège 2 : requireSync: true sur un observable async te crash au runtime

requireSync: true te promet un signal non-nullable. Mais si tu le passes à un observable qui n'émet pas synchronement à la souscription, tu prends :

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

Le cas typique : tu crois qu'un Subject est synchrone (faux, il n'émet rien tant que .next() n'a pas été appelé) ou tu mappes un observable HTTP en oubliant qu'il ne fait pas de startWith.

// crash : Subject n'a pas de valeur initiale
private clicks$ = new Subject<MouseEvent>();
click = toSignal(this.clicks$, { requireSync: true }); // boom

// ok : BehaviorSubject a toujours une valeur
private clicks$ = new BehaviorSubject<MouseEvent | null>(null);
click = toSignal(this.clicks$, { requireSync: true });

// ok : of() émet sync à la souscription
flag = toSignal(of(true), { requireSync: true });

// ok : HTTP avec startWith
state = toSignal(
  this.http.get<User>('/api/user').pipe(startWith(null)),
  { requireSync: true },
);

Règle : requireSync: true ne s'utilise qu'avec un observable dont tu peux prouver qu'il émet à la souscription. BehaviorSubject, ReplaySubject avec buffer >= 1, of(), ou un pipe qui démarre par startWith().


Piège 3 : toObservable(signal) coalesce les writes synchrones

Le piège le plus subtil. Tu transformes un signal en observable et tu écris plusieurs fois dedans dans le même tick :

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { tap } from 'rxjs';

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button (click)="bumpThreeTimes()">+3</button>`,
})
export class Counter {
  count = signal(0);
  count$ = toObservable(this.count).pipe(
    tap(value => console.log('emitted', value)),
  );

  constructor() {
    this.count$.subscribe();
  }

  bumpThreeTimes(): void {
    this.count.set(1);
    this.count.set(2);
    this.count.set(3);
  }
}

Tu cliques. Combien de nouveaux console.log ? Un seul : emitted 3 (en plus du emitted 0 initial, émis à la souscription via le ReplaySubject(1)). Les valeurs 1 et 2 sont mortes en chemin.

toObservable() repose sur un effect() interne, qui fire après le microtask courant. Si tu écris 3 fois dans le signal sync, l'effect ne voit que la dernière valeur. C'est le comportement glitch-free de la réactivité Signals, pas un bug.

Si tu as besoin de capter chaque write, n'utilise pas toObservable(). Reste en RxJS (Subject) ou pousse les events dans une queue :

// alternative : Subject quand tu veux chaque event
private bump$ = new Subject<number>();
bump$.pipe(tap(v => console.log('emitted', v))).subscribe();

bumpThreeTimes(): void {
  this.bump$.next(1);
  this.bump$.next(2);
  this.bump$.next(3);
}

Le test mental : si l'historique des valeurs intermédiaires a un sens métier (events de clic, frappes clavier, messages WebSocket), reste en RxJS. Si seule la valeur courante compte (état UI, filtre actif, sélection), Signals + toObservable() font le job.


Piège 4 : toSignal() hors d'un injection context crash sans message clair

Tu factorise une fonction utilitaire qui combine plusieurs observables en un signal :

import { toSignal } from '@angular/core/rxjs-interop';
import { combineLatest, map, Observable } from 'rxjs';

export function combinedStatus(
  a$: Observable<boolean>,
  b$: Observable<boolean>,
) {
  return toSignal(
    combineLatest([a$, b$]).pipe(map(([a, b]) => a && b)),
    { initialValue: false },
  );
}

Tu l'appelles depuis une factory provider, un APP_INITIALIZER, ou un test, et tu prends :

NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.

toSignal() appelle inject(DestroyRef) en interne pour s'auto-clean. Hors d'un constructeur ou d'un field initializer, ça pète.

Le fix : passer l'Injector explicitement.

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

export function combinedStatus(
  a$: Observable<boolean>,
  b$: Observable<boolean>,
  injector: Injector,
) {
  return toSignal(
    combineLatest([a$, b$]).pipe(map(([a, b]) => a && b)),
    { initialValue: false, injector },
  );
}

Et tu passes inject(Injector) depuis l'appelant. Pareil pour toObservable() qui prend la même option. Si tu écris des fonctions de composition réactive, l'option injector est non négociable.


Piège 5 : toObservable() n'émet pas de façon synchrone au subscribe

Tu exposes un signal en observable pour un service legacy qui consomme du RxJS, et tu tentes de lire la valeur courante dans la foulée du subscribe() :

import { Injectable, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';

@Injectable({ providedIn: 'root' })
export class ThemeStore {
  private themeSignal = signal<'light' | 'dark'>('dark');
  theme$ = toObservable(this.themeSignal);

  toggle(): void {
    this.themeSignal.update(t => (t === 'light' ? 'dark' : 'light'));
  }
}
let current: 'light' | 'dark' | undefined;
this.themeStore.theme$.subscribe(v => (current = v));
console.log(current); // undefined, pas 'dark'

Contrairement à un BehaviorSubject, toObservable() n'émet pas la valeur de façon synchrone au subscribe. Sa mécanique repose sur un effect() interne, qui fire après le microtask courant : la première valeur arrive donc au tick suivant, pas dans la ligne qui suit le subscribe(). Tout code qui lit current tout de suite voit undefined.

Le réflexe classique est d'ajouter shareReplay(1) « pour ne pas rater la valeur ». C'est inutile ici : toObservable() est déjà adossé à un ReplaySubject(1), donc un abonné tardif reçoit bien la dernière valeur connue dès son subscribe (de façon asynchrone). Le replay est intégré, le seul vrai écueil est le timing : asynchrone, jamais synchrone.

Donc :

// ❌ redondant : toObservable() rejoue déjà la dernière valeur
theme$ = toObservable(this.themeSignal).pipe(
  shareReplay({ bufferSize: 1, refCount: true }),
);

// ✅ suffisant : le ReplaySubject(1) interne fait le replay
theme$ = toObservable(this.themeSignal);

Si tu as besoin de la valeur immédiatement (lecture synchrone), ne passe pas par l'observable : lis le signal directement (this.themeSignal()). Et si un consommateur RxJS veut repasser en signal, utilise toSignal(theme$, { initialValue: 'dark' }), surtout pas requireSync: true, puisque toObservable() n'émet jamais de façon synchrone (tu retomberais sur le Piège 2).

Variante la plus propre quand tu peux : expose le signal directement et laisse chaque consumer faire son toObservable() au besoin. Tu évites la frontière sync/async sur ta surface publique.


Piège 6 : manualCleanup: true oublié dans une factory longue durée

toSignal() se désabonne automatiquement via DestroyRef. Dans un composant ou service scopé, c'est ce que tu veux. Mais dans un service providedIn: 'root', le DestroyRef n'est jamais déclenché pendant la vie de l'app, donc l'auto-cleanup n'est pas un problème.

Le piège est inversé : quand tu vois manualCleanup: true dans une codebase, demande-toi pourquoi. C'est souvent un copier-coller qui désactive le cleanup sans raison, et tu fuites en effet sur des sous-composants.

// Suspect : pourquoi manualCleanup ici ?
search = toSignal(this.searchService.results$, {
  initialValue: [],
  manualCleanup: true,
});

Pour un composant standard, vire l'option. Le default fait exactement ce qu'il faut.

manualCleanup: true n'a de sens que dans un scénario rare : tu construis un signal long-vécu depuis un contexte court (par exemple, un service singleton qui crée des signaux à partir d'observables passés par des composants éphémères). Tu prends alors la responsabilité de te désabonner à la main, généralement en passant un injector rattaché à un EnvironmentInjector dont tu maîtrises le cycle de vie.


Before / after : la check-list mentale

Quand tu écris toSignal() ou toObservable(), deux questions à te poser avant de valider :

toSignal(source$) :

  1. La source émet-elle sync à la souscription ? Si oui, requireSync: true. Si non, initialValue.
  2. Suis-je dans un injection context ? Si non, passer injector.

toObservable(signal) :

  1. Y a-t-il des writes synchrones multiples à capturer ? Si oui, n'utilise pas toObservable(), reste en RxJS.
  2. Ai-je besoin de la valeur de façon synchrone au subscribe ? Si oui, toObservable() ne convient pas (émission asynchrone) : lis le signal directement.

Et la règle de fond : plus tu mélanges RxJS et Signals dans le même composant, plus tu paies en complexité. Chaque pont est une frontière où les sémantiques s'opposent. Pour un nouveau composant, choisis un seul des deux modèles. L'interop est un outil de migration, pas un style d'architecture.


Récap actionnable

  • toSignal() sans option par défaut → type T | undefined. Ajoute initialValue ou requireSync: true.
  • requireSync: true se valide uniquement sur des observables qui émettent à la souscription (BehaviorSubject, of(), pipe startWith()).
  • toObservable() ne voit que la dernière valeur d'une suite de writes synchrones. Si tu as besoin du détail, reste en RxJS.
  • Si tu appelles ces APIs hors composant / service (factory, util, test), passe injector explicitement.
  • toObservable() émet de façon asynchrone (jamais synchrone au subscribe), mais rejoue déjà la dernière valeur aux abonnés tardifs grâce à son ReplaySubject(1) interne : pas besoin de rajouter shareReplay(1).
  • Laisse le cleanup automatique en place. manualCleanup: true est un piège, pas une optimisation.

Tu pousses ces 6 réflexes en code review et tu coupes 80% des bugs d'interop. Pour le reste, la vraie réponse est de ne pas mélanger : un composant 100% Signals ou 100% RxJS reste plus facile à raisonner qu'un hybride.


Envie de creuser Angular ?

👉🏼 ➡️ Découvre EasyAngularKit

📧 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.