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.

É 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
@Inputmuda, - um evento (clique, digitação) acontece dentro dele,
- um
asyncpipe 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.

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, ) {} }
Juntando os pilares, o fluxo completo de uma mudança de estado é sempre o mesmo caminho, em uma via só:

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:

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

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.
