Destino Dev

Blog

Signals + RxJS em rotas lazy com Standalone Components e Route Providers: modo errado vs modo certo

Por John Pires · 11 de março de 2026

Signals + RxJS em rotas lazy com Standalone Components e Route Providers: modo errado vs modo certo

1) Quando e por que usar

Usar Signals junto com RxJS em rotas lazy e standalone components resolve dois problemas práticos: escopo de estado por rota e integração previsível entre APIs reativas e a nova reatividade do Angular. Em aplicações reais você quer que um conjunto de componentes lazy tenha um estado isolado — sem poluir o root injector — e quer evitar subscriptions manuais espalhadas por todo componente. Route providers permitem escopar serviços à rota; signals permitem modelar estado reativo local de forma síncrona; e RxJS continua sendo a melhor ferramenta para composition de streams assíncronos e side-effects (HTTP, websockets, timers).

O objetivo aqui não é somente mostrar API: é revisar padrões usados em código real e destacar onde se costuma errar. Vou mostrar um jeito errado que gera leaks, acoplamento e duplicação, e depois um jeito correto que usa route providers, sinais, toSignal/toObservable e efeitos para manter tudo testável e fácil de depurar.

Quando aplicar

Aplicar esse padrão quando você tem:

  • Rotas lazy com componentes standalone (loadComponent)

  • Estado local que não deve persistir além da rota

  • Requisições HTTP ou streams que necessitam ser integrados a um estado reativo local

Quando evitar

Não precisa usar esse padrão para componentes pequenos e efêmeros sem estado complexo. Se o estado for global, mantenha-o em um store global (NgRx/SignalStore) em vez de route-scoped providers. Evite misturar tudo quando um simples Input/Output resolveria o caso.

2) Comparação com alternativas

Existem alternativas para gerenciar estado e reatividade em rotas lazy. Aqui está uma visão direta, como em um code review:

Alternativa A — Serviço no root + Observables

Vantagens: simples, conhecido. Desvantagens: estado compartilhado entre instâncias de rota, difícil de isolar, problema com cleanup e leaks. Ex.: service providedIn: 'root' e componentes chamando subscribe manualmente.

Alternativa B — Store global (NgRx, Akita)

Vantagens: previsibilidade, devtools, time-travel. Desvantagens: overhead em rotas pequenas; escopar por rota é mais trabalhoso. Usar store global para estado que só precisa existir durante a rota é overkill.

Alternativa C — Route providers + signals (recomendado quando aplicável)

Vantagens: escopo claro, lifecycle alinhado com rota, sinais permitem leitura síncrona no template e integração com RxJS quando preciso. Desvantagens: exige entendimento de new reactivity APIs (signals, toSignal/toObservable, inject).

Dica: Prefira route-scoped providers quando o estado precisa durar exatamente enquanto a rota estiver ativa. Isso reduz bugs de “estado fantasma” quando o usuário reentra na rota.

3) Implementação progressiva

Vou apresentar três passos práticos: primeiro o modo errado (code review), depois a correção incremental e por fim a versão madura que usa signals + toSignal + route providers.

3.1 Exemplo errado — serviço no root + subscribe manual

Contexto: o componente faz fetch de dados e atualiza o template. O serviço vive no root.

// O que faz: fetch simples de itens via HttpClient, serviço fornecido em root (errado em rotas lazy)
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class BadFeatureService {
  private _items$ = new BehaviorSubject([]);
  readonly items$ = this._items$.asObservable();

  constructor(private http: HttpClient) {}

  load() {
    this.http.get('/api/items').subscribe(data => this._items$.next(data));
  }
}

// Componente standalone que consome o serviço
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-bad-feature',
  template: {{ i.name }}
})
export class BadFeatureComponent implements OnInit, OnDestroy {
  items: any[] = [];
  sub: Subscription | null = null;
  constructor(private svc: BadFeatureService) {}

  ngOnInit() {
    this.svc.load();
    this.sub = this.svc.items$.subscribe(data => (this.items = data));
  }

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

O que acontece: funciona, mas abre três problemas visíveis durante code review:

  • serviço no root compartilha estado entre entradas da rota;

  • component precisa gerenciar subscription manual e potencialmente esquecer o cleanup;

  • não há vínculo claro entre lifecycle da rota e lifecycle do estado.

3.2 Correção incremental — usar async pipe e evitar root leaking

Contexto: mudamos para async pipe para evitar unsubscribe manual, mas o serviço ainda está no root — o leak de escopo permanece.

// O que faz: expondo Observable e usando async pipe no componente
@Component({
  selector: 'app-better-feature',
  template:{{ i.name }}
})
export class BetterFeatureComponent {
  constructor(public svc: BadFeatureService) {}
}

O que acontece: removemos unsubscribe manual, mas ainda temos o problema de escopo. Além disso, ler estados no template via Observable impede leitura síncrona em lógica condicional (ex.: computed derived values sem async pipe múltiplos).

3.3 Modo certo — Route provider + Signals + toSignal

Contexto: encapsular estado numa classe de store fornecida pela rota. Converter Observable do HttpClient para Signal com toSignal. O componente injeta a store e usa sinais diretamente no template (sem async pipe).

// O que faz: FeatureStore escopada por rota, expõe signals; integra RxJS via toSignal
import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs';

@Injectable()
export class FeatureStore {
  private _items = signal([]);
  readonly items = this._items.asReadonly();

  constructor(private http: HttpClient) {}

  load() {
    // toSignal converte Observable em Signal, suportando valores iniciais
    const s = toSignal(this.http.get('/api/items'));
    // atribui o valor retornado quando disponível
    this._items.set(s() ?? []);
  }
}

// Rota lazy com provider específico
export const routes = [
  {
    path: 'feature',
    loadComponent: () => import('./feature.component').then(m => m.FeatureComponent),
    providers: [FeatureStore]
  }
];

// Componente standalone que injeta a store e usa signal no template
import { Component, inject } from '@angular/core';
import { FeatureStore } from './feature.store';

@Component({
  standalone: true,
  selector: 'app-feature',
  template: {{ item.name }}
})
export class FeatureComponent {
  store = inject(FeatureStore);
  constructor() { this.store.load(); }
}

O que acontece: FeatureStore é criado quando a rota é ativada e destruído quando a rota sai. O componente lê sinais síncronamente sem subscriptions manuais, e a integração com HTTP é tratada pelo toSignal para bridge entre RxJS e Signals.

4) Integração com ecossistema real

Em aplicações reais você precisa integrar: efeitos (side-effects como logs), guards/resolvers, lazy providers, testes unitários e SSR. Vou comentar padrões e armadilhas comuns. A abordagem recomendada dispõe os sinais como a interface pública da store e usa RxJS internamente quando houver composition avançada (switchMap, retry, debounce) ou multicasting.

Bridge entre RxJS e Signals

Use toSignal para converter Observables quando você precisa de um valor síncrono no template ou em um computed. Use toObservable quando precisar expor um signal como Observable para operadores RxJS ou interop com bibliotecas que exigem Observable.

// O que faz: expor signal como Observable e vice-versa
import { Injectable, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs';
import { concatMap } from 'rxjs/operators';

@Injectable()
export class HybridStore {
  private _filter = signal('');
  readonly filter$ = toObservable(this._filter);

  // exemplo: compor filter$ com outras streams
  constructor() {
    this.filter$.pipe(concatMap(value => /* ... */ [])).subscribe();
  }
}

O que acontece: toObservable permite usar operadores RxJS e manter a fonte original como signal.

Guards, Resolvers e Route Providers

Quando uma rota define providers, guards e resolvers podem injetar esses providers porque o injector da rota é criado antes da ativação. Isso permite que um resolver inicialize o estado na FeatureStore antes do componente ser criado — útil para evitar flicker no UI.

// O que faz: resolver injeta a store e chama load para pré-carregar dados
import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';

@Injectable()
export class FeatureResolver implements Resolve {
  constructor(private store: FeatureStore) {}
  resolve() {
    return this.store.load(); // load pode retornar Promise/Observable para o resolver esperar
  }
}

// rota com provider e resolver
export const routes = [
  {
    path: 'feature',
    loadComponent: () => import('./feature.component').then(m => m.FeatureComponent),
    providers: [FeatureStore, FeatureResolver],
    resolve: { _: FeatureResolver }
  }
];

O que acontece: o resolver assegura que a store já tenha carregado dados antes do componente renderizar. Isso melhora UX e mantém o store como fonte única de verdade.

Erros comuns, armadilhas e dicas práticas

Em code reviews costumo ver padrões repetidos que geram bugs em produção. Listei os mais frequentes:

  • Service providedIn: 'root' por padrão sem avaliar o escopo: conduz a estado compartilhado e bugs quando a intenção é escopo por rota.

  • Subscribe em componentes para cada Observable sem async pipe ou without cleanup — memory leaks fáceis de detectar.

  • Misturar sinais e observables sem adaptar lifecycles: usar toSignal dentro de construtor sem considerar que load() pode ser chamada múltiplas vezes e sobrescrever valores indevidos.

  • Expor mutáveis (signals não readonly) diretamente para templates com set/patch acessíveis; sempre expor somente leitura.

  • Injeção via constructor em standalone components é ok, mas usar `inject()` facilita padrões funcionais e testáveis, e evita constructors inchados.

Erro clássico: transformar Observable em BehaviorSubject no serviço e chamar next() em vários lugares — isso mistura responsabilidades e torna testes frágeis.

Checklist de pitfall com exemplos rápidos

Pequena lista de checagem antes de aprovar um PR:

  1. O serviço é route-scoped quando deveria ser?

  2. As subscriptions estão encapsuladas (async pipe / signals) e não no componente?

  3. Signals expostas são readonly?

  4. toSignal/toObservable são usados para interop e não como substituto de design reativo?

  5. Existe cleanup claro para side-effects (effects canceláveis)?

5) Checklist de qualidade para produção

Antes de promover para produção, aplique este checklist. É prático, direto e focado em segurança do estado, performance e observabilidade.

  • Escopo de providers: confirme se cada provider foi intencionalmente colocado em route.providers ou root.

  • Signals read-only: todas as propriedades que o componente lê devem ser readonly; mutações só via métodos da store.

  • Bridge explícita: para cada toSignal/toObservable, documente por que foi necessário.

  • Resolvers para UX crítico: use resolve para prevenir flicker quando necessário, mas prefira lazy loading in-place para menos bloqueios.

  • Testes unitários: store unit tests que simulam ciclo de vida da rota; mocks de HttpClient e assertions em signals.

  • Performance: evite conversões desnecessárias entre Observable/Signal em hot loops; cache quando fizer sentido.

  • Observability: adicione logs/telemetria dentro de efeitos e pontos de load para rastrear erros em produção.

Exemplo de teste unitário simples

Contexto: testar que a store chama HttpClient e atualiza o signal.

// O que faz: teste unitário com HttpTestingController para FeatureStore
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('FeatureStore', () => {
  let httpMock: HttpTestingController;
  let store: FeatureStore;

  beforeEach(() => {
    TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [FeatureStore] });
    httpMock = TestBed.inject(HttpTestingController);
    store = TestBed.inject(FeatureStore);
  });

  it('should load items', () => {
    store.load();
    const req = httpMock.expectOne('/api/items');
    req.flush([{ name: 'a' }]);
    expect(store.items()).toEqual([{ name: 'a' }]);
  });
});

O que acontece: o teste assegura que a store reage ao response e atualiza o signal, sem necessidade de montar o componente.

Conclusão prática

Resumo direto de code review: se sua intenção é ter estado que dura somente enquanto a rota está ativa, use route providers. Se você quer leitura síncrona e menos boilerplate de subscriptions, use signals. Para integrar com o universo RxJS, prefira toSignal e toObservable como pontes, não como substitutos de design.

O padrão recomendado que passou pelo code review aqui é simples e seguro:

  1. Coloque a store como provider da rota (providers: [FeatureStore]).

  2. Expose sinais readonly da store; métodos públicos mutam o estado.

  3. Use toSignal para consumir Observables do HttpClient quando você precisa do valor síncrono no template/computed.

  4. Use resolvers para pré-carregar quando necessário e effects para side-effects observáveis.

Aplicando essas práticas você reduz leaks, melhora testabilidade e mantém código previsível quando trabalhar com standalone components e rotas lazy.

Se precisar, posso transformar os snippets em um repo de referência com testes end-to-end e exemplos de SSR/Angular Universal. Informe a stack exata (versão do Angular) que você usa e eu adapto o código às APIs disponíveis.

Assuntos: Angular, signals, rxjs, standalone, routing, route-providers

Leia também