Volver al Blog
Angular Development23 de febrero de 202611 min de lectura

NgRx en la Práctica: Cuándo Usarlo y Cuándo No

DR

Doug Ribeiro

11 min de lectura

NgRx puede frenar la velocidad de sprint de un equipo pequeño durante meses. Este es un patrón de falla, no el postmortem de un equipo en particular, y siempre tiene la misma forma: unos pocos desarrolladores, plazos ajustados, y alguien que propone poner cada pieza de estado en NgRx mientras todos asienten. Los primeros sprints se van en escribir actions, reducers, selectors y effects para estado que podía haber vivido en dos servicios. Después, agregar un campo a un slice significa editar la action, la interfaz de estado, el reducer, el selector y el componente que lo lee. Al final alguien recurre a meta-reducers para gestionar la complejidad introducida para gestionar la complejidad. La respuesta rara vez es arrancar NgRx. Se gana su lugar para el estado que genuinamente cruza features. Lo que tiene que cambiar es el default de poner todo en el store.

Qué Resuelve Realmente NgRx

El argumento honesto a favor de NgRx no es la experiencia de desarrollador. Es la auditabilidad y la coordinación de estado entre features. Cuando tienes estado que es leído y mutado por componentes en diferentes feature modules lazy-loaded, cuando necesitas Redux DevTools con viaje en el tiempo para debuggear problemas de estado en producción, cuando múltiples equipos necesitan contratos explícitos sobre cómo ocurren las transiciones de estado, ahí es donde NgRx justifica su ceremonia.

En BMO, esas condiciones eran reales. Trabajé en la plataforma digital de solicitud de préstamos del banco, que está construida como micro-frontends. NgRx ya estaba en su lugar cuando me uní, y las decisiones de arquitectura pertenecen a un equipo separado. Habiendo trabajado dentro de ella, creo que es la decisión correcta para ese tipo de producto: en una aplicación financiera grande, un servicio con un BehaviorSubject se volvería imposible de debuggear en seis meses. NgRx hace las transiciones explícitas y rastreables.

El Patrón Clásico, Sin Maquillaje

Antes de recurrir a Signal Store o cualquier alternativa más ligera, entiende contra qué estás decidiendo. El patrón clásico de Actions, Reducers, Selectors, Effects es verboso por diseño. La verbosidad es la documentación. Aquí hay un slice ilustrativo para un envío genérico de solicitud de préstamo, escrito para este post y no tomado de ninguna base de código real:

typescript
// actions/application.actions.ts
export const submitApplication = createAction('[Application] Submit');
export const submitApplicationSuccess = createAction(
  '[Application] Submit Success',
  props<{ applicationId: string; offerAmount: number }>()
);
export const submitApplicationFailure = createAction(
  '[Application] Submit Failure',
  props<{ error: string; retryable: boolean }>()
);

// reducers/application.reducer.ts
export interface ApplicationState {
  applicationId: string | null;
  offerAmount: number | null;
  submitting: boolean;
  error: string | null;
  retryable: boolean;
}

const initialState: ApplicationState = {
  applicationId: null,
  offerAmount: null,
  submitting: false,
  error: null,
  retryable: false
};

export const applicationReducer = createReducer(
  initialState,
  on(submitApplication, state => ({ ...state, submitting: true, error: null })),
  on(submitApplicationSuccess, (state, { applicationId, offerAmount }) => ({
    ...state, applicationId, offerAmount, submitting: false
  })),
  on(submitApplicationFailure, (state, { error, retryable }) => ({
    ...state, error, retryable, submitting: false
  }))
);
typescript
// effects/application.effects.ts
@Injectable()
export class ApplicationEffects {
  private actions$ = inject(Actions);
  private applicationService = inject(ApplicationService);

  submit$ = createEffect(() =>
    this.actions$.pipe(
      ofType(submitApplication),
      exhaustMap(() =>
        this.applicationService.submit().pipe(
          map(result => submitApplicationSuccess(result)),
          catchError(err => of(submitApplicationFailure({
            error: err.message,
            retryable: err.status >= 500
          })))
        )
      )
    )
  );
}

Uso exhaustMap aquí, no switchMap. En el envío de una solicitud de préstamo, no cancelas solicitudes en curso cuando el usuario hace doble clic. Ese tipo de decisión se hace explícita con Effects e invisible con un método de servicio.

Cuándo Omitir el Patrón Clásico

  • Estado que es local a un feature y nunca se lee fuera de él
  • Carga de datos asíncrona simple sin dependencias entre features
  • Proyectos de una persona o equipos pequeños donde la pista de auditoría de Redux DevTools no aporta valor
  • Fases de prototipo y MVP donde no puedes permitirte el costo del boilerplate
  • Estado que cambia en respuesta a input del usuario dentro de un solo formulario o paso de wizard

Para todos estos, un servicio con signals es hoy la decisión correcta. En versiones de Angular anteriores a la 16, donde signals no es una opción, la misma jugada funciona con servicios simples y BehaviorSubjects: saca del store el estado tipo wizard y mantén NgRx solo para el estado que genuinamente cruza features. Cientos de líneas de actions, reducers y selectors pueden colapsar en un par de servicios pequeños que son más simples de testear, más simples de debuggear y más rápidos de cambiar.

NgRx Signal Store: El Punto Medio

NgRx Signal Store salió como developer preview en NgRx v17 y quedó listo para producción en NgRx v18. Ojo que esas son versiones de NgRx, no de Angular. Es genuinamente bueno. Te da la gestión de estado estructurada y explícita de NgRx con signals en lugar de observables de RxJS, y sin la ceremonia de Actions, Reducers, Effects para casos que no la necesitan.

typescript
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { DocumentService, UploadedDocument } from './document.service';

interface DocumentUploadState {
  applicationId: string | null;
  documents: UploadedDocument[];
  uploading: boolean;
  error: string | null;
}

export const DocumentUploadStore = signalStore(
  { providedIn: 'root' },
  withState<DocumentUploadState>({
    applicationId: null,
    documents: [],
    uploading: false,
    error: null
  }),
  withComputed(({ documents }) => ({
    documentCount: computed(() => documents().length),
    hasRequiredDocuments: computed(() => documents().length >= 2)
  })),
  withMethods((store, service = inject(DocumentService)) => ({
    uploadDocument: rxMethod<File>(pipe(
      tap(() => patchState(store, { uploading: true })),
      switchMap(file =>
        service.upload(file).pipe(
          tapResponse(
            doc => patchState(store, { documents: [...store.documents(), doc], uploading: false }),
            err => patchState(store, { error: err.message, uploading: false })
          )
        )
      )
    ))
  }))
);

tapResponse se movió de @ngrx/component-store a @ngrx/operators en NgRx 18, que es el import que quieres en cualquier versión actual. Uso Signal Store para estado a nivel de feature que es demasiado complejo para un servicio crudo pero no necesita auditoría con Redux DevTools. El umbral es aproximadamente: múltiples operaciones asíncronas con estados de carga interdependientes, o estado compartido entre tres o más componentes dentro del mismo feature.

La Decisión Que Realmente Uso

  • Estado local del componente: signals directamente en el componente
  • Estado a nivel de feature con complejidad moderada: NgRx Signal Store
  • Estado global con lecturas entre features, orquestación asíncrona compleja, o requisitos de auditoría: NgRx clásico
  • Si heredaste NgRx clásico: mantenlo, no lo reescribas, los patrones son sólidos
Usa NgRx cuandoEl estado se comparte entre 3+ …Necesitas Redux DevTools para d…Efectos secundarios async compl…Equipo grande con contratos de …Evita NgRx cuandoEl estado es local a un solo fe…Reactividad simple a nivel de c…App pequeña o mediana con owner…El equipo prefiere menos boiler…VS

NgRx no hace tu aplicación más profesional. Hace las transiciones de estado explícitas. Eso solo es valioso si estabas perdiendo el control de ellas.

El error que veo más frecuentemente es equipos recurriendo a NgRx porque señala seriedad, no porque resuelve un problema que tienen. Si nunca has despachado un bug causado por estado compartido impredecible entre features, probablemente no necesitas NgRx clásico todavía. Construye con signals y Signal Store. Agrega la maquinaria Redux completa cuando la complejidad realmente lo demande.

Referencias

AngularNgRxState ManagementTypeScript