Capítulo 17: Gerenciamento de Estado Corporativo com NgRx SignalStore

Especialização em Front-End com Angular • Angular 18+ • Signals, Standalone Architecture, RxJS, NgRx e SSR


🗺️ Mapa Conceitual da Arquitetura do NgRx SignalStore

flowchart TD
    Comp["Componente Consumidor"] -->|"Invocação de Método / Dispatch"| Methods["withMethods() (Ações & rxMethod)"]

    subgraph SIGNAL_STORE["NgRx SignalStore"]
        State["withState() (Estado Reativo Centralizado)"]
        Computed["withComputed() (Seletores Memoizados)"]
        Methods
        Hooks["withHooks() (onInit / onDestroy)"]
    end

    Methods -->|"patchState()"| State
    State --> Computed
    Computed -->|"Signals Nativos"| Comp
    State -->|"Signals Nativos"| Comp

    Methods -->|"Chamada HTTP Externa"| Backend["API REST"]
    Backend -->|"Retorno de Dados"| Methods

    style Comp fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style SIGNAL_STORE fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style Backend fill:#fff3e0,stroke:#ff9800,stroke-width:2px

🏛️ 1. O Fim do Boilerplate Redux: A Chegada do SignalStore

Durante quase uma década, aplicações corporativas em Angular recorreram ao padrão Redux (NgRx Store clássico), exigindo a escrita de dezenas de arquivos complementares para uma única funcionalidade: actions.ts, reducers.ts, effects.ts, selectors.ts e facades.ts.

Com a introdução do NgRx SignalStore (membro oficial da suíte @ngrx/signals):

  • O estado global ou modular é declarado de forma funcional, tipada e extensível.
  • O consumo pelos componentes ocorre diretamente via Signals nativos, sem a necessidade de pipes assíncronos (AsyncPipe).
  • Atualizações atômicas e imutáveis são feitas com a função utilitária patchState().
  • O código de gerenciamento de estado é reduzido em até 75% de linhas, mantendo previsibilidade e manutenibilidade rigorosas.

⚙️ 2. Construindo uma Store Completa com signalStore

Veja a implementação de uma store corporativa para gestão de faturas financeiras:

// stores/faturas.store.ts
import { inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed } from '@angular/core';
import { pipe, switchMap, tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';

export interface Fatura {
  id: string;
  cliente: string;
  valor: number;
  status: 'PENDENTE' | 'PAGO' | 'ATRASADO';
}

interface FaturasState {
  faturas: Fatura[];
  carregando: boolean;
  filtroStatus: 'TODOS' | 'PENDENTE' | 'PAGO' | 'ATRASADO';
  termoBusca: string;
}

const estadoInicial: FaturasState = {
  faturas: [],
  carregando: false,
  filtroStatus: 'TODOS',
  termoBusca: ''
};

export const FaturasStore = signalStore(
  { providedIn: 'root' }, // Injeção Singleton Global
  withState(estadoInicial),

  // Seletores derivados reativos (Computed Signals)
  withComputed(({ faturas, filtroStatus, termoBusca }) => ({
    faturasFiltradas: computed(() => {
      const lista = faturas();
      const status = filtroStatus();
      const busca = termoBusca().toLowerCase();

      return lista.filter(f => {
        const matchesStatus = status === 'TODOS' || f.status === status;
        const matchesBusca = f.cliente.toLowerCase().includes(busca);
        return matchesStatus && matchesBusca;
      });
    }),
    totalPendente: computed(() => {
      return faturas()
        .filter(f => f.status === 'PENDENTE' || f.status === 'ATRASADO')
        .reduce((acc, f) => acc + f.valor, 0);
    })
  })),

  // Métodos de mutação síncrona e orquestração assíncrona
  withMethods((store, http = inject(HttpClient)) => ({
    definirFiltroStatus(filtroStatus: FaturasState['filtroStatus']): void {
      patchState(store, { filtroStatus });
    },

    definirTermoBusca(termoBusca: string): void {
      patchState(store, { termoBusca });
    },

    // Método assíncrono conectado a RxJS para buscar dados do backend
    carregarFaturas: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { carregando: true })),
        switchMap(() => http.get<Fatura[]>('/api/faturas')),
        tap(faturas => patchState(store, { faturas, carregando: false }))
      )
    )
  })),

  // Ciclo de vida da Store
  withHooks({
    onInit(store) {
      console.log('FaturasStore inicializada. Carregando dados...');
      store.carregarFaturas();
    }
  })
);

💻 3. Consumo Reativo no Componente Standalone

No componente, consumir o SignalStore é tão simples quanto injetar um serviço tradicional. Todas as fatias de estado e seletores computados já chegam prontos como Signals:

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FaturasStore } from './stores/faturas.store';

@Component({
  selector: 'app-gestao-faturas',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="painel">
      <header>
        <h2>Faturamento da Empresa</h2>
        <p>Total em Aberto: {{ store.totalPendente() | currency:'BRL' }}</p>
      </header>

      <div class="controles">
        <button (click)="store.definirFiltroStatus('TODOS')">Todas</button>
        <button (click)="store.definirFiltroStatus('PENDENTE')">Pendentes</button>
        <button (click)="store.definirFiltroStatus('PAGO')">Pagas</button>
      </div>

      @if (store.carregando()) {
        <p>Atualizando faturas...</p>
      } @else {
        <ul>
          @for (fatura of store.faturasFiltradas(); track fatura.id) {
            <li>
              <span>{{ fatura.cliente }}</span> - 
              <strong>{{ fatura.valor | currency:'BRL' }}</strong> 
              <span>({{ fatura.status }})</span>
            </li>
          } @empty {
            <li>Nenhuma fatura encontrada com os filtros atuais.</li>
          }
        </ul>
      }
    </div>
  `
})
export class GestaoFaturasComponent {
  readonly store = inject(FaturasStore);
}

🧩 4. Criação de Recursos Reutilizáveis com signalStoreFeature

Você pode modularizar sua arquitetura criando extensões reutilizáveis (por exemplo, um feature store genérico de paginação ou controle de requisição HTTP):

import { signalStoreFeature, withState, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

export function withPagination(pageSizePadrao = 10) {
  return signalStoreFeature(
    withState({ paginaAtual: 1, itensPorPagina: pageSizePadrao, totalRegistros: 0 }),
    withComputed(({ totalRegistros, itensPorPagina }) => ({
      totalPaginas: computed(() => Math.ceil(totalRegistros() / itensPorPagina()))
    }))
  );
}

// Compondo no store final:
// signalStore(withState(...), withPagination(20), withMethods(...))

⚖️ 5. Comparativo: NgRx Clássico vs SignalStore

Aspecto NgRx Clássico (Actions / Reducers) NgRx SignalStore Moderno
Linhas de Código (Boilerplate) Elevadíssimo (4 a 5 arquivos por entidade) Mínimo (1 arquivo conciso e modular)
Tipo de Reatividade Observables de RxJS (store.select(...)) Signals Nativos (store.meuDado())
Integração com Template Requer | async em quase todas as tags Chamada direta sem overhead
Escopo de Injeção Quase exclusivamente Root Global Suporta Root Global ou Escopo Local por Componente
Curva de Aprendizado Íngreme Suave e intuitiva

⚠️ 6. Armadilhas Comuns e Anti-Patterns

  1. Mutação Direta de Arrays no patchState:
    • Erro: patchState(store, state => { state.faturas.push(novo); return state; }).
    • Correção: Mantenha imutabilidade: patchState(store, state => ({ faturas: [...state.faturas, novo] })).
  2. Transformar Todo Estado Local em Global:
    • Não coloque variáveis temporárias da view (como se um dropdown está aberto) em stores globais. Use signal() local no componente.

🛡️ 7. Boas Práticas e Arquitetura Limpa

  • Stores de Feature vs Stores Globais: Registre stores que atendem telas inteiras no array providers: [MinhaFeatureStore] do componente pai ou na rota, garantindo que o estado seja limpo automaticamente quando o usuário sair da página.

📋 8. Cheat-Sheet: Resumo Executivo

export const MinhaStore = signalStore(
  withState({ contador: 0 }),
  withComputed(({ contador }) => ({ dobro: computed(() => contador() * 2) })),
  withMethods(store => ({
    incrementar() { patchState(store, s => ({ contador: s.contador + 1 })); }
  }))
);