O que é o NgRx e como ele traz organização e performance pro Angular

O que é o NgRx e como ele traz organização e performance pro Angular

"Usa NgRx que resolve" é um conselho que todo mundo que mexe com Angular já ouviu. O problema é que, sem entender o porquê, vira só mais uma biblioteca pra decorar. Antes de chegar no NgRx em si, vale a pena entender um problema mais básico que ele resolve de quebra: como o Angular decide quando redesenhar a tela.

Um adendo antes de começar: nada disso depende de Signals. O NgRx que vou descrever aqui é construído em cima do RxJS e existe desde muito antes dos Signals chegarem ao Angular. Uso ele hoje mesmo num sistema no trabalho que não pode subir de versão do Angular no momento, sem Signals disponíveis, e o ganho de organização e performance que vou mostrar segue valendo do mesmo jeito.

O problema antes do NgRx: como o Angular decide o que redesenhar

Por padrão, o Angular usa uma biblioteca chamada Zone.js pra saber quando "alguma coisa pode ter mudado". O Zone.js intercepta praticamente toda API assíncrona do navegador (setTimeout, clique, fetch, Promise) e, toda vez que uma dessas dispara, avisa o Angular: "aconteceu algo, melhor conferir".

O Angular então percorre a árvore inteira de componentes, de cima a baixo, checando se algum dado mudou. Isso é a estratégia padrão de detecção de mudanças, e funciona bem numa tela pequena. Numa aplicação grande, com centenas de componentes, um clique num botão qualquer faz o Angular reconferir componentes que não têm nada a ver com aquele clique. Caro, e desnecessário na maior parte do tempo.

Fluxo da detecção padrão do Angular: um evento assíncrono aciona o Zone.js, que avisa o Angular a percorrer toda a árvore de componentes

É pra esse custo que existe a estratégia OnPush: você marca um componente pra sair desse ciclo automático. Com OnPush, o Angular só reconfere aquele componente quando:

  • a referência de um @Input muda,
  • um evento (clique, digitação) acontece dentro dele,
  • um async pipe emite um novo valor,
  • alguém chama markForCheck() manualmente.

O detalhe que pega muita gente de surpresa é a palavra "referência". Se você tem um array no estado e só dá push() nele, o array continua sendo o mesmo objeto na memória, então o Angular olha, vê a mesma referência de antes e conclui que nada mudou, mesmo tendo mudado. Com OnPush, mutar estado direto simplesmente não funciona. É preciso sempre criar um objeto novo pra representar o novo estado.

Fluxo do OnPush: evento assíncrono aciona o Zone.js, que verifica se o Input mudou de referência, se o evento veio de dentro do componente ou se um async pipe emitiu, ignorando o componente ou reconferindo só ele conforme o resultado

Ou seja: OnPush é bem mais barato, mas exige disciplina: nunca mutar estado, sempre criar um novo. É exatamente essa disciplina que o NgRx impõe por padrão, então antes de ser sobre "organização", ele já nasce alinhado com o que o Angular precisa pra performar bem.

O que é o NgRx, afinal

NgRx é uma biblioteca de gerenciamento de estado pra Angular, inspirada no Redux (que veio do mundo React) e construída em cima do RxJS. A ideia central é simples: o estado que várias telas ou componentes precisam compartilhar fica guardado num lugar só, chamado Store, e a única forma de mudar esse estado é despachando uma Action, que é processada por uma função pura chamada Reducer. O reducer nunca altera o estado antigo, ele sempre devolve um objeto novo. É o mesmo comportamento que o OnPush estava pedindo lá atrás, só que agora garantido pela própria arquitetura, não pela boa vontade de quem escreve o código.

Desde quando dá pra usar

O NgRx existe desde os primeiros anos do Angular moderno, e cada versão major dele acompanha a versão major correspondente do Angular: NgRx 18 é feito pra rodar com Angular 18, NgRx 20 com Angular 20, e assim por diante.

Existe também uma opção mais recente, o @ngrx/signals: um pacote separado pra montar a Store usando signalStore() em vez do trio Action/Reducer/ Effect. Chegou em versão de testes na v17 do NgRx e virou estável na v18. Os pilares que vêm a seguir são a base clássica (a que uso no sistema do trabalho, e a que o @ngrx/signals reorganiza por baixo dos panos), tem uma seção mais à frente mostrando como o mesmo exemplo fica nessa versão mais nova, pra quem já estiver num projeto com Signals.

Os pilares do NgRx

Pra não ficar só na teoria, imagina um carrinho de compras simples: adicionar produto, tirar produto, aplicar cupom de desconto. Esse é o mesmo exemplo do formigadev-ngrx-example que vou usar nos prints do DevTools mais à frente. Formato de estado:

export interface Produto {
  id: string;
  nome: string;
  preco: number;
}

export interface ItemCarrinho extends Produto {
  quantidade: number;
}

export interface CarrinhoState {
  itens: ItemCarrinho[];
  descontoPercentual: number;
}

export const initialState: CarrinhoState = { itens: [], descontoPercentual: 0 };
  • Store: onde o estado mora. Pensa nele como o "banco de dados" daquela tela enquanto ela está aberta.

  • Actions: eventos com nome, e às vezes um dado junto. É a única forma permitida de dizer "quero mudar alguma coisa".

    export const itemAdicionado = createAction(
      '[Produtos] Item Adicionado',
      props<{ produto: Produto }>(),
    );
  • Reducers: função pura que recebe o estado atual e uma action, e devolve um estado novo. "Pura" aqui quer dizer: mesma entrada, mesma saída, sempre, sem mexer em nada fora dela.

    export const carrinhoReducer = createReducer(
      initialState,
      on(itemAdicionado, (state, { produto }) => ({
        ...state,
        itens: [...state.itens, { ...produto, quantidade: 1 }],
      })),
    );
  • Selectors: função que lê um pedaço específico do estado. Tem memoização de fábrica, ou seja, se nada que ele depende mudou, ele nem recalcula, só devolve o valor de antes.

    export const selecionarSubtotal = createSelector(
      selecionarItens,
      (itens) => itens.reduce((total, item) => total + item.preco * item.quantidade, 0),
    );
  • Effects: onde mora o efeito colateral, tipo chamar uma API. Um effect escuta uma action, faz o trabalho assíncrono, e no final dispara outra action com o resultado. Nunca muda o estado direto, só dispara a próxima action, quem muda o estado continua sendo o reducer.

    @Injectable()
    export class CarrinhoEffects {
      aplicarCupom$ = createEffect(() =>
        this.actions$.pipe(
          ofType(cupomSolicitado),
          switchMap(({ codigo }) =>
            this.cupomService.validar(codigo).pipe(
              map((percentual) => cupomAplicado({ codigo, percentual })),
              catchError((erro: Error) => of(cupomFalhou({ mensagem: erro.message }))),
            ),
          ),
        ),
      );
    
      constructor(
        private readonly actions$: Actions,
        private readonly cupomService: CupomService,
      ) {}
    }

    Fluxo de um Effect: a action disparada é escutada pelo effect, que faz uma chamada assíncrona e dispara uma action de sucesso ou de erro, e o reducer atualiza o estado em qualquer um dos dois casos

Juntando os pilares, o fluxo completo de uma mudança de estado é sempre o mesmo caminho, em uma via só:

Fluxo completo do NgRx: o componente dispara uma action, o reducer calcula o novo estado, a Store guarda esse estado, o selector devolve o pedaço que mudou, e o componente com OnPush recebe a nova referência e atualiza

Do lado do componente, esse fluxo inteiro vira só duas linhas: uma pra disparar a action, outra pra ler o selector, sem precisar de Signal nenhum pra isso:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<li *ngFor="let item of itens$ | async">{{ item.nome }}</li>`,
})
export class CarrinhoComponent {
  private readonly store = inject(Store);

  protected readonly itens$ = this.store.select(selecionarItens);

  adicionar(produto: Produto) {
    this.store.dispatch(itemAdicionado({ produto }));
  }
}

itens$ é um Observable comum, sem Signal envolvido. O async pipe no template se inscreve nele e chama markForCheck() sozinho a cada novo valor emitido, o terceiro gatilho de OnPush da lista lá do início do post. É assim que um app em Angular 15, sem nenhum Signal disponível, continua tendo controle fino sobre quando cada componente reprocessa.

Nenhum componente muda o estado direto. Todo mundo passa pela mesma fila, o que facilita rastrear "quem mudou o quê" quando alguma coisa dá errado, e é exatamente aí que entra a próxima parte.

O carrinho acima é a versão enxuta, só pro raciocínio dos pilares. A versão completa (com Angular Material, os estados de carregando/erro do cupom e teste do reducer) está em formigadev-ngrx-example — o carrinho.actions.ts, carrinho.reducer.ts e carrinho.effects.ts de lá são praticamente os mesmos trechos que apareceram aqui. É de lá também que vêm os prints do DevTools logo abaixo.

Versão moderna: os mesmos pilares com @ngrx/signals

Caso você já esteja num projeto usando Signals, o @ngrx/signals monta a mesma ideia de outro jeito: em vez de arquivo de action, reducer e selector separados, tudo fica dentro de um signalStore() só. O mesmo carrinho, nessa versão:

export const CarrinhoStore = signalStore(
  { providedIn: 'root' },
  withState<CarrinhoState>({ itens: [], descontoPercentual: 0 }),
  withComputed(({ itens }) => ({
    subtotal: computed(() => itens().reduce((total, item) => total + item.preco * item.quantidade, 0)),
  })),
  withMethods((store) => ({
    adicionar(produto: Produto) {
      patchState(store, (state) => ({
        itens: [...state.itens, { ...produto, quantidade: 1 }],
      }));
    },
  })),
);
@Component({ changeDetection: ChangeDetectionStrategy.OnPush /* ... */ })
export class CarrinhoComponent {
  private readonly carrinhoStore = inject(CarrinhoStore);
  protected readonly subtotal = this.carrinhoStore.subtotal;

  adicionar(produto: Produto) {
    this.carrinhoStore.adicionar(produto);
  }
}

withState é o Reducer e o Store num lugar só. withComputed é o Selector, memoizado automaticamente igual ao computed() do Angular. withMethods é onde a Action e o Reducer se misturam: patchState continua criando um objeto novo por baixo dos panos, a mesma disciplina de imutabilidade que o OnPush pede lá do início do post, só que você não escreve isso à mão.

Existe também um meio-termo, sem reescrever tudo pro signalStore: manter Store, Reducer e Effect clássicos como estão (o que apareceu na seção anterior) e só trocar o select() + async pipe do componente por selectSignal(), disponível no Store normal desde a v17. Migra a leitura pro Signal sem tocar no resto.

Não muda o raciocínio, muda só onde cada peça mora. Effects também tem equivalente (rxMethod), mas o essencial já dá pra sentir só com estado e leitura. Se o projeto for novo e já usa Signals em tudo, essa versão tende a ter menos arquivo pra um estado simples. Se for um projeto maior, com vários times mexendo no mesmo estado, a separação explícita de Action/Reducer/ Effect ainda tem valor: força um nome pra cada mudança possível, o que ajuda a ler o histórico do DevTools na próxima seção.

NgRx DevTools: ver o estado acontecendo

Como toda mudança de estado passa por uma action com nome, dá pra registrar essas actions numa linha do tempo e inspecionar o estado em cada ponto dela. É isso que o pacote @ngrx/store-devtools faz, integrado com a extensão Redux DevTools do navegador.

Na prática, ele mostra três coisas:

  • Log de actions: cada action disparada, na ordem, com timestamp.
  • Inspeção de estado: o estado completo (ou o diff) em qualquer ponto dessa linha do tempo.
  • Time travel: dá pra voltar e avançar entre actions, reproduzindo exatamente o estado da aplicação naquele momento, sem precisar reconstruir o cenário manualmente.

Rodando o formigadev-ngrx-example com a aba Redux do DevTools aberta, o log de actions mostra cada clique virando uma action, na ordem:

Log de actions no Redux DevTools mostrando as actions disparadas ao adicionar produtos e aplicar um cupom no carrinho de exemplo

E clicando em qualquer uma dessas actions, o estado completo da aplicação naquele ponto exato da timeline:

Árvore de estado no Redux DevTools mostrando o conteúdo da Store do carrinho de exemplo em um ponto específico da timeline de actions

Isso muda a forma de debugar. Em vez de tentar reproduzir um bug do zero adivinhando os passos, dá pra pedir o log de actions (ou reproduzir localmente) e simplesmente andar na timeline até achar o exato ponto onde o estado ficou errado.

Fechando o raciocínio

O NgRx costuma ser vendido como "organização": um lugar só pra guardar estado, um jeito só de mudar ele. Isso é real, mas é só metade da história. A outra metade é que essa mesma disciplina (nunca mutar, sempre um objeto novo) é o exato requisito que o OnPush precisa pra parar de depender do Zone.js varrendo a árvore inteira a cada evento. Organização e performance, nesse caso, são o mesmo hábito visto de dois ângulos diferentes.

Henrique Guedes FormigaHenrique Guedes FormigaSênior Angular Developer