Ir para o conteúdo
Framework com Framework1700 palavras

Como usar Angular com Redux: Guia Completo

Introdução

Gerenciar estado em aplicações Angular de médio e grande porte é um dos desafios que separam projetos amadores de soluções profissionais. Quando sua aplicação cresce, os dados começam a fluir de formas imprevisíveis entre componentes, serviços viram repositórios temporários de estado, e qualquer refatoração se transforma em uma operação de risco.

É exatamente nesse cenário que a combinação de Angular com Redux se torna poderosa. No ecossistema Angular, a implementação mais consolidada do padrão Redux é o NgRx: uma biblioteca reativa que aplica os princípios de store única, imutabilidade e fluxo unidirecional de dados usando RxJS como base.

Neste guia, vou mostrar como integrar Angular com NgRx do zero, com exemplos práticos extraídos de cenários reais. O objetivo não é apenas ensinar a sintaxe, mas ajudar você a entender quando e por que adotar essa arquitetura.

Por Que Usar Angular com Redux

O problema que o Redux resolve no Angular

O Angular já oferece um sistema de injeção de dependências robusto e serviços que funcionam como singletons. Para aplicações pequenas, isso é suficiente. Porém, quando você tem dezenas de componentes compartilhando e modificando o mesmo estado, surgem problemas concretos:

  • Componentes acoplados que dependem de serviços intermediários para trocar dados.
  • Dificuldade em rastrear qual parte do código alterou determinado valor.
  • Lógica de negócio espalhada entre componentes, serviços e guards.
  • Testes unitários que exigem mocks complexos para simular estado.

O Redux resolve esses problemas com três princípios: uma única fonte de verdade (a store), estado somente leitura (alterado apenas via actions) e mudanças feitas por funções puras (reducers).

Casos de uso reais onde essa combinação brilha

Ao longo dos anos, trabalhei em projetos onde o NgRx foi determinante para manter a sanidade do time:

Dashboards corporativos com dados em tempo real: quando múltiplos widgets consomem e atualizam os mesmos dados via WebSocket, a store centralizada evita inconsistências visuais e race conditions.

Sistemas de e-commerce com carrinho complexo: cupons, frete, itens com variações, estoque em tempo real. Cada ação do usuário dispara uma cadeia de recálculos que, sem uma store previsível, vira um emaranhado de subscriptions.

Aplicações com fluxos de formulário multi-etapa: quando o usuário pode navegar entre etapas, salvar rascunhos e retomar mais tarde, manter esse estado na store simplifica drasticamente a lógica.

Aplicações offline-first: a store serve como cache local previsível, facilitando a sincronização quando a conexão é restaurada.

Setup do NgRx no Angular: Passo a Passo

Instalação das dependências

Vamos começar com um projeto Angular já existente. Execute os seguintes comandos:

TYPESCRIPT
// No terminal, instale os pacotes principais do NgRx
// ng add @ngrx/store@latest
// ng add @ngrx/effects@latest
// ng add @ngrx/store-devtools@latest
 
// O ng add cuida de registrar os módulos automaticamente.
// Após a instalação, seu app.module.ts (ou app.config.ts para standalone)
// terá algo como:
 
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideEffects(),
    provideStoreDevtools({
      maxAge: 25,
      logOnly: !isDevMode(),
    }),
  ],
};

O pacote @ngrx/store-devtools é opcional, mas altamente recomendado. Ele conecta sua aplicação à extensão Redux DevTools do navegador, permitindo inspecionar cada action disparada e o estado resultante.

Estrutura de pastas recomendada

Uma organização que funciona bem em projetos reais agrupa os artefatos do NgRx por feature:

Text
src/
  app/
    features/
      products/
        state/
          products.actions.ts
          products.reducer.ts
          products.selectors.ts
          products.effects.ts
          products.state.ts
        components/
        products.module.ts

Essa estrutura mantém a lógica de estado próxima da feature que a utiliza, facilitando a navegação e a manutenção.

Exemplo Prático: Gerenciamento de Produtos

Vamos construir um módulo completo de gerenciamento de produtos para ilustrar todos os conceitos fundamentais do NgRx.

Definindo o estado e as actions

TYPESCRIPT
// products.state.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}
 
export interface ProductsState {
  products: Product[];
  selectedProductId: string | null;
  loading: boolean;
  error: string | null;
}
 
export const initialProductsState: ProductsState = {
  products: [],
  selectedProductId: null,
  loading: false,
  error: null,
};
 
// products.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Product } from './products.state';
 
export const ProductsPageActions = createActionGroup({
  source: 'Products Page',
  events: {
    'Load Products': emptyProps(),
    'Select Product': props<{ productId: string }>(),
    'Delete Product': props<{ productId: string }>(),
  },
});
 
export const ProductsApiActions = createActionGroup({
  source: 'Products API',
  events: {
    'Products Loaded Successfully': props<{ products: Product[] }>(),
    'Products Loaded Failure': props<{ error: string }>(),
    'Product Deleted Successfully': props<{ productId: string }>(),
    'Product Deleted Failure': props<{ error: string }>(),
  },
});

Observe a separação entre actions originadas na página (ProductsPageActions) e actions originadas na API (ProductsApiActions). Essa convenção facilita o rastreamento da origem de cada evento no DevTools.

Construindo o reducer e os selectors

TYPESCRIPT
// products.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialProductsState } from './products.state';
import { ProductsPageActions, ProductsApiActions } from './products.actions';
 
export const productsReducer = createReducer(
  initialProductsState,
 
  on(ProductsPageActions.loadProducts, (state) => ({
    ...state,
    loading: true,
    error: null,
  })),
 
  on(ProductsApiActions.productsLoadedSuccessfully, (state, { products }) => ({
    ...state,
    products,
    loading: false,
  })),
 
  on(ProductsApiActions.productsLoadedFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),
 
  on(ProductsPageActions.selectProduct, (state, { productId }) => ({
    ...state,
    selectedProductId: productId,
  })),
 
  on(ProductsApiActions.productDeletedSuccessfully, (state, { productId }) => ({
    ...state,
    products: state.products.filter((p) => p.id !== productId),
  }))
);
 
// products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductsState } from './products.state';
 
export const selectProductsState =
  createFeatureSelector<ProductsState>('products');
 
export const selectAllProducts = createSelector(
  selectProductsState,
  (state) => state.products
);
 
export const selectProductsLoading = createSelector(
  selectProductsState,
  (state) => state.loading
);
 
export const selectProductsError = createSelector(
  selectProductsState,
  (state) => state.error
);
 
export const selectSelectedProduct = createSelector(
  selectProductsState,
  (state) =>
    state.products.find((p) => p.id === state.selectedProductId) ?? null
);
 
export const selectInStockProducts = createSelector(
  selectAllProducts,
  (products) => products.filter((p) => p.inStock)
);

Os selectors são memoizados por padrão. Isso significa que o selectInStockProducts só será recalculado quando a lista de produtos realmente mudar, evitando re-renders desnecessários.

Implementando os effects

TYPESCRIPT
// products.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ProductsPageActions, ProductsApiActions } from './products.actions';
import { ProductsService } from '../services/products.service';
import { catchError, map, mergeMap, of, switchMap } from 'rxjs';
 
@Injectable()
export class ProductsEffects {
  private actions$ = inject(Actions);
  private productsService = inject(ProductsService);
 
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsPageActions.loadProducts),
      switchMap(() =>
        this.productsService.getAll().pipe(
          map((products) =>
            ProductsApiActions.productsLoadedSuccessfully({ products })
          ),
          catchError((error) =>
            of(
              ProductsApiActions.productsLoadedFailure({
                error: error.message,
              })
            )
          )
        )
      )
    )
  );
 
  deleteProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductsPageActions.deleteProduct),
      mergeMap(({ productId }) =>
        this.productsService.delete(productId).pipe(
          map(() =>
            ProductsApiActions.productDeletedSuccessfully({ productId })
          ),
          catchError((error) =>
            of(
              ProductsApiActions.productDeletedFailure({
                error: error.message,
              })
            )
          )
        )
      )
    )
  );
}

Note o uso de switchMap para o carregamento (cancela requisições anteriores) e mergeMap para a deleção (permite operações paralelas). Essa escolha de operador é intencional e reflete o comportamento desejado para cada caso.

Consumindo a store no componente

TYPESCRIPT
// products-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { ProductsPageActions } from '../state/products.actions';
import {
  selectAllProducts,
  selectProductsLoading,
  selectProductsError,
} from '../state/products.selectors';
 
@Component({
  selector: 'app-products-list',
  standalone: true,
  imports: [AsyncPipe, NgFor, NgIf],
  template: `
    <div *ngIf="loading$ | async" class="spinner">Carregando produtos...</div>
 
    <div *ngIf="error$ | async as error" class="error-banner">
      Erro ao carregar produtos: {{ error }}
    </div>
 
    <ul *ngIf="(loading$ | async) === false">
      <li *ngFor="let product of products$ | async">
        <span>{{ product.name }} - R$ {{ product.price }}</span>
        <button (click)="onSelect(product.id)">Selecionar</button>
        <button (click)="onDelete(product.id)">Remover</button>
      </li>
    </ul>
  `,
})
export class ProductsListComponent implements OnInit {
  private store = inject(Store);
 
  products$ = this.store.select(selectAllProducts);
  loading$ = this.store.select(selectProductsLoading);
  error$ = this.store.select(selectProductsError);
 
  ngOnInit(): void {
    this.store.dispatch(ProductsPageActions.loadProducts());
  }
 
  onSelect(productId: string): void {
    this.store.dispatch(ProductsPageActions.selectProduct({ productId }));
  }
 
  onDelete(productId: string): void {
    this.store.dispatch(ProductsPageActions.deleteProduct({ productId }));
  }
}

O componente ficou extremamente enxuto. Ele não conhece o serviço HTTP, não manipula estado diretamente e não tem lógica de negócio. Sua única responsabilidade é despachar actions e reagir ao estado via selectors.

Boas Práticas para Angular com NgRx em Produção

Nomeie actions como eventos, não como comandos

Em vez de criar uma action chamada setLoading, descreva o que aconteceu: loadProducts, productsLoadedSuccessfully. Isso torna o log do DevTools uma narrativa legível do que ocorreu na aplicação.

Use a abordagem de feature state

Registre cada slice de estado no módulo ou na rota correspondente usando provideState('products', productsReducer). Isso garante lazy loading do estado junto com a feature, evitando carregar reducers que o usuário pode nunca acessar.

Não coloque tudo na store

Nem todo estado precisa estar no NgRx. Estado de formulário local, estados de UI puramente visuais (como "este dropdown está aberto") e dados que não são compartilhados entre componentes podem (e devem) permanecer no componente. Uma regra prática: se o estado precisa sobreviver à navegação ou é consumido por múltiplos componentes, ele pertence à store.

Teste seus selectors e reducers isoladamente

Reducers são funções puras, o que torna seus testes triviais. Selectors memoizados podem ser testados com projector, sem precisar instanciar a store. Priorize testar esses artefatos, pois é onde reside a lógica de negócio.

Considere o NgRx SignalStore para projetos novos

A partir do NgRx 17, existe o @ngrx/signals, que oferece gerenciamento de estado baseado em signals do Angular. Para projetos novos e menores, essa alternativa pode ser mais leve e idiomática. O NgRx Store clássico continua sendo a melhor escolha para aplicações grandes com fluxos assíncronos complexos.

Conclusão

A combinação de Angular com Redux (via NgRx) não é sobre adicionar complexidade ao projeto. É sobre trazer previsibilidade ao gerenciamento de estado quando a complexidade já existe. A store única, as actions descritivas, os reducers puros e os effects isolados criam uma arquitetura onde cada mudança de estado é rastreável, testável e reversível.

O investimento inicial em boilerplate se paga rapidamente em projetos com múltiplos desenvolvedores, fluxos assíncronos elaborados e requisitos de manutenção a longo prazo. O DevTools sozinho já justifica a adoção em cenários de debug complexo.

Comece aplicando o NgRx em uma feature isolada do seu projeto atual. Sinta o fluxo de actions, observe o estado no DevTools, escreva testes para os reducers. Quando esse padrão se tornar natural, você terá uma ferramenta poderosa no seu arsenal para enfrentar qualquer nível de complexidade no frontend.

Guias Relacionados

Explore mais guias técnicos

Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.

Ver todos os guias