Cuando Angular Signals se estabilizó en v17, la reacción inmediata en la comunidad fue enmarcarlo como un reemplazo de NgRx. Entiendo por qué. Signals es built-in, es más simple, y cubre mucho terreno. Pero corrí NgRx en una plataforma de tarjetas de crédito con más de 50 000 usuarios, y no arrancaría un store así solo porque existen los signals. Las dos herramientas resuelven problemas diferentes.
En qué son buenos los Signals
Signals son una primitiva reactiva síncrona. Un signal contiene un valor. Cualquier cosa que lea ese valor en un contexto reactivo, un template, un computed(), o un effect(), rastrea la dependencia automáticamente y se actualiza cuando el signal cambia. Sin pipes, sin suscripciones, sin async.
import { signal, computed } from '@angular/core';
import { Account } from './accounts.model';
const selectedAccountId = signal<string | null>(null);
const accounts = signal<Account[]>([]);
const selectedAccount = computed(() =>
accounts().find(a => a.id === selectedAccountId())
);
// In template: {{ selectedAccount()?.balance | currency }}
// Updates instantly when either signal changesPara estado scoped a un feature, los signals son dramáticamente menos ceremoniosos que NgRx. Un servicio basado en signals para un feature reemplaza un reducer, un conjunto de selectors, y el boilerplate asociado con unas treinta líneas de TypeScript. Aquí va uno a modo de ejemplo para un feature de solicitud de préstamo.
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { LendingApiService, LoanApplication } from './lending-api.service';
@Injectable({ providedIn: 'root' })
export class LendingStateService {
private api = inject(LendingApiService);
private destroyRef = inject(DestroyRef);
private _applications = signal<LoanApplication[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
readonly applications = this._applications.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly pendingApplications = computed(() =>
this._applications().filter(a => a.status === 'pending')
);
loadApplications(): void {
this._loading.set(true);
this.api.getApplications()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: apps => {
this._applications.set(apps);
this._loading.set(false);
},
error: err => {
this._error.set(err.message);
this._loading.set(false);
}
});
}
}loadApplications corre fuera de un contexto de inyección, así que a takeUntilDestroyed hay que pasarle el DestroyRef explícitamente. Sin eso, la subscription sobrevive al servicio y escribe en signals que ya nadie lee.
Donde los Signals empiezan a mostrar grietas
Los Signals no tienen log de acciones. No puedes reproducir transiciones de estado. No puedes abrir Redux DevTools y ver qué pasó entre que el usuario hizo clic en 'Submit' y apareció el estado de error. En un entorno regulado como un banco, esa auditabilidad importa para depurar incidentes de producción.
Los Signals tampoco tienen un patrón built-in para side effects async complejos. Cargar datos al inicializar una ruta, reintentar en caso de fallo, mostrar una actualización optimista mientras la respuesta real está en vuelo, y luego revertir en caso de error: aquí es donde NgRx Effects con su ciclo estandarizado de action-effect-reducer justifica su costo de complejidad.
En qué es realmente bueno NgRx
NgRx no es solo un contenedor de estado. Es un log de auditoría. Cada cambio de estado es una acción despachada con un nombre y un payload. Cuando una solicitud se envía y el usuario termina en un estado de error, puedes abrir Redux DevTools y reproducir exactamente lo que pasó: LoadApplications despachado, LoadApplicationsSuccess recibido, SubmitApplication despachado, SubmitApplicationFailure recibido con el error del servidor. Esa capacidad de replay es donde el store se paga solo en tiempo de depuración.
// lending.actions.ts
import { createAction, props } from '@ngrx/store';
import { Confirmation } from './lending-api.service';
export const submitApplication = createAction(
'[Lending] Submit Application',
props<{ applicationId: string }>()
);
export const submitApplicationSuccess = createAction(
'[Lending] Submit Application Success',
props<{ confirmation: Confirmation }>()
);
export const submitApplicationFailure = createAction(
'[Lending] Submit Application Failure',
props<{ error: string }>()
);
// lending.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { LendingApiService } from './lending-api.service';
@Injectable()
export class LendingEffects {
private actions$ = inject(Actions);
private lendingApi = inject(LendingApiService);
submitApplication$ = createEffect(() =>
this.actions$.pipe(
ofType(submitApplication),
exhaustMap(({ applicationId }) =>
this.lendingApi.submit(applicationId).pipe(
map(confirmation => submitApplicationSuccess({ confirmation })),
catchError(err => of(submitApplicationFailure({ error: err.message })))
)
)
)
);
}Cómo lo dividiría en una app grande
El estado del shell de la aplicación va en el store NgRx: autenticación, permisos, notificaciones globales, datos cross-feature. En una app grande partida en features lazy-loaded o micro-frontends, estos se comparten a través de las fronteras y necesitan sobrevivir los cambios de ruta. El audit trail importa aquí.
El estado de feature va en servicios basados en signals con la forma del LendingStateService de arriba: signals para los datos locales, una lectura de NgRx para el contexto de sesión del usuario, y ninguna acción de NgRx para estado de UI puramente local como qué paso está activo o si un panel está expandido.
Conectando NgRx y Signals con toSignal
toSignal de @angular/core/rxjs-interop convierte cualquier observable en un signal. Este es el puente entre el store de NgRx y templates basados en signals.
import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUserPermissions } from './auth.selectors';
@Component({
standalone: true,
template: `
@if (permissions()?.canApply) {
<button (click)="apply()">Apply Now</button>
}
`
})
export class LendingComponent {
private store = inject(Store);
// NgRx selector consumed as a signal. No async pipe, no subscription.
permissions = toSignal(this.store.select(selectUserPermissions));
}NgRx SignalStore: Lo mejor de ambos mundos
NgRx SignalStore usa signals internamente pero agrega la inmutabilidad estilo reducer y el patrón rxMethod para manejar async. Si quieres la ergonomía de signals con el enfoque estructurado de NgRx, vale la pena evaluarlo. En una plataforma establecida que corre el store NgRx clásico, una migración de store es una decisión de arquitectura, no algo que decide un desarrollador de features. Para proyectos nuevos, empezaría aquí en lugar de elegir entre los dos. Recorro un SignalStore con withState, withComputed y un rxMethod para el trabajo async en mi post sobre NgRx en la práctica, así que no repito el código acá.
- Proyecto nuevo, equipo pequeño, estado scoped a feature: comienza con servicios de signals
- Proyecto nuevo, industria regulada, equipo grande: NgRx o NgRx SignalStore
- Proyecto existente con NgRx: usa toSignal para conectar selectors a templates, agrega servicios de signals para nuevos features
- Lo que nunca debería vivir en signals: estado que múltiples features lazy-loaded leen y escriben concurrentemente
Los Signals reducen la ceremonia. NgRx impone disciplina. La mejor configuración usa ambos: signals para estado de feature, NgRx para asuntos de toda la aplicación.
Referencias
- Angular Signals overview. https://angular.dev/guide/signals
- toSignal API reference. https://angular.dev/api/core/rxjs-interop/toSignal
- NgRx official documentation. https://ngrx.io/docs
- NgRx SignalStore guide. https://ngrx.io/guide/signals/signal-store