Voltar ao Blog
Angular Development21 de dezembro de 20259 min de leitura

Angular Signals vs NgRx: Qual Você Deveria Usar?

DR

Doug Ribeiro

9 min de leitura

Quando Angular Signals ficou estável na v17, a reação imediata da comunidade foi enquadrar como um substituto para NgRx. Eu entendo por quê. Signals é built-in, é mais simples, e cobre bastante terreno. Mas eu rodei NgRx em uma plataforma de cartão de crédito com mais de 50.000 usuários, e não arrancaria um store desses só porque signals existem. As duas ferramentas resolvem problemas diferentes.

SignalsEstado com escopo de featureBoilerplate mínimoAtualizações síncronasSem precisar de DevToolsSignal services (~30 linhas)NgRxEstado global da aplicaçãoTrilha de auditoria no Redux De…Orquestração assíncrona complexaContratos explícitos de actionsCoordenação entre featuresVS

No Que Signals é Bom

Signals é uma primitiva reativa síncrona. Um signal guarda um valor. Qualquer coisa que lê esse valor em um contexto reativo, um template, um computed(), ou um effect(), rastreia a dependência automaticamente e atualiza quando o signal muda. Sem pipes, sem subscriptions, sem async.

typescript
import { signal, computed } from '@angular/core';
import { Account } from './accounts.model';

const selectedAccountId = signal<string | null>(null);
const accounts = signal<Account[]>([]);

const selectedAccount = computed(() =>
  accounts().find(a => a.id === selectedAccountId())
);

// In template: {{ selectedAccount()?.balance | currency }}
// Updates instantly when either signal changes

Para estado com escopo de feature, signals tem dramaticamente menos cerimônia que NgRx. Um service baseado em signals para uma feature substitui um reducer, um conjunto de selectors e o boilerplate associado com umas trinta linhas de TypeScript. Aqui vai um a título de exemplo para uma feature de solicitação de empréstimo.

typescript
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { LendingApiService, LoanApplication } from './lending-api.service';

@Injectable({ providedIn: 'root' })
export class LendingStateService {
  private api = inject(LendingApiService);
  private destroyRef = inject(DestroyRef);

  private _applications = signal<LoanApplication[]>([]);
  private _loading = signal(false);
  private _error = signal<string | null>(null);

  readonly applications = this._applications.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();

  readonly pendingApplications = computed(() =>
    this._applications().filter(a => a.status === 'pending')
  );

  loadApplications(): void {
    this._loading.set(true);
    this.api.getApplications()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next: apps => {
          this._applications.set(apps);
          this._loading.set(false);
        },
        error: err => {
          this._error.set(err.message);
          this._loading.set(false);
        }
      });
  }
}

loadApplications roda fora de um contexto de injeção, então takeUntilDestroyed precisa receber o DestroyRef explicitamente. Sem isso, a subscription sobrevive ao service e escreve em signals que ninguém lê mais.

Onde Signals Começa a Mostrar Limitações

Signals não tem log de actions. Você não consegue replayer transições de estado. Não consegue abrir o Redux DevTools e ver o que aconteceu entre o usuário clicar em 'Enviar' e o estado de erro aparecer. Em um ambiente regulado como um banco, essa auditabilidade importa para debugar incidentes de produção.

Signals também não tem padrão built-in para side effects assíncronos complexos. Carregar dados na inicialização da rota, retentar em caso de falha, mostrar uma atualização otimista enquanto a resposta real está em trânsito, e depois reverter em caso de erro: é onde NgRx Effects com seu ciclo padronizado de action-effect-reducer justifica seu custo de complexidade.

No Que NgRx é Realmente Bom

NgRx não é apenas um container de estado. É um log de auditoria. Toda mudança de estado é uma action despachada com nome e payload. Quando uma solicitação é enviada e o usuário termina em um estado de erro, você pode abrir o Redux DevTools e replayer exatamente o que aconteceu: LoadApplications despachado, LoadApplicationsSuccess recebido, SubmitApplication despachado, SubmitApplicationFailure recebido com o erro do servidor. É nessa capacidade de replay que o store se paga em tempo de debug.

typescript
// lending.actions.ts
import { createAction, props } from '@ngrx/store';
import { Confirmation } from './lending-api.service';

export const submitApplication = createAction(
  '[Lending] Submit Application',
  props<{ applicationId: string }>()
);
export const submitApplicationSuccess = createAction(
  '[Lending] Submit Application Success',
  props<{ confirmation: Confirmation }>()
);
export const submitApplicationFailure = createAction(
  '[Lending] Submit Application Failure',
  props<{ error: string }>()
);

// lending.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { LendingApiService } from './lending-api.service';

@Injectable()
export class LendingEffects {
  private actions$ = inject(Actions);
  private lendingApi = inject(LendingApiService);

  submitApplication$ = createEffect(() =>
    this.actions$.pipe(
      ofType(submitApplication),
      exhaustMap(({ applicationId }) =>
        this.lendingApi.submit(applicationId).pipe(
          map(confirmation => submitApplicationSuccess({ confirmation })),
          catchError(err => of(submitApplicationFailure({ error: err.message })))
        )
      )
    )
  );
}

Como Eu Dividiria Isso em uma App Grande

Estado do shell da aplicação pertence ao store NgRx: autenticação, permissões, notificações globais, dados compartilhados entre features. Em uma app grande dividida em features carregadas lazily ou micro-frontends, esses são compartilhados entre fronteiras e precisam sobreviver a mudanças de rota. A trilha de auditoria importa aqui.

Estado de feature pertence a services baseados em signals no formato do LendingStateService acima: signals para dados locais, uma leitura do NgRx para o contexto de sessão do usuário, e nenhuma action NgRx para estado de UI puramente local como qual etapa está ativa ou se um painel está expandido.

Conectando NgRx e Signals com toSignal

toSignal de @angular/core/rxjs-interop converte qualquer observable para um signal. Essa é a ponte entre o store NgRx e templates baseados em signals.

typescript
import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUserPermissions } from './auth.selectors';

@Component({
  standalone: true,
  template: `
    @if (permissions()?.canApply) {
      <button (click)="apply()">Apply Now</button>
    }
  `
})
export class LendingComponent {
  private store = inject(Store);

  // NgRx selector consumed as a signal. No async pipe, no subscription.
  permissions = toSignal(this.store.select(selectUserPermissions));
}

NgRx SignalStore: O Melhor dos Dois Mundos

NgRx SignalStore usa signals internamente mas adiciona a imutabilidade estilo reducer e o padrão rxMethod para lidar com async. Se você quer a ergonomia de signals com a abordagem estruturada do NgRx, vale avaliar. Em uma plataforma estabelecida rodando o store NgRx clássico, uma migração de store é uma decisão de arquitetura, não uma escolha de quem desenvolve features. Para projetos novos, eu começaria por aqui ao invés de escolher entre os dois. Eu percorro um SignalStore com withState, withComputed e um rxMethod para a parte async no meu post sobre NgRx na prática, então não repito o código aqui.

  • Projeto novo, time pequeno, estado com escopo de feature: comece com signal services
  • Projeto novo, indústria regulada, time grande: NgRx ou NgRx SignalStore
  • Projeto NgRx existente: use toSignal para conectar selectors aos templates, adicione signal services para features novas
  • O que nunca deveria ficar em signals: estado que múltiplas features carregadas lazily leem e escrevem concorrentemente

Signals reduz cerimônia. NgRx impõe disciplina. O melhor setup usa ambos: signals para estado de feature, NgRx para preocupações da aplicação toda.

ComponenteLê signals direto, sem boilerplate de subscription 1 Signal service (estado da feature)toSignal() liga os selectors do NgRx a signals 2 NgRx Store (estado global)Actions, Reducers, Selectors, Effects 3 HTTP / APIs externasEfeitos colaterais gerenciados pelos Effects do NgRx 4

Referências

AngularSignalsNgRxState ManagementTypeScript