Lección 3 de 414 min
Estado global: NgRx y NGXS
El patrón Redux, acciones, reducers, selectores y efectos, SignalStore y cuándo no hace falta un store global.
En una tienda online, el carrito aparece en la cabecera, en la ficha del producto, en el resumen del pedido y en el menú lateral. Cuatro componentes leen y modifican el mismo dato, a veces a la vez, y cuando algo falla nadie sabe quién cambió qué.
Los stores globales, como NgRx y NGXS, centralizan ese estado y obligan a cambiarlo de una forma predecible, siempre igual y fácil de depurar. En esta lección verás el patrón en el que se basan, cómo se escribe en cada librería y, sobre todo, cuándo no te hace falta ninguna.
El patrón Redux
El patrón Redux
Un store único, acciones que describen lo ocurrido y reducers puros que calculan el estado.
- Origen:
- Redux, 2015
- Flujo:
- unidireccional
El patrón lo popularizó la librería Redux, del mundo React, y se apoya en tres ideas. Todo el estado compartido vive en un único store. Nadie lo modifica directamente: los componentes despachan acciones, objetos que describen lo que ha pasado («producto agregado»). Y un reducer, una función pura, recibe el estado actual y la acción y devuelve un estado nuevo, sin tocar el anterior.
El resultado es un flujo de datos unidireccional: la vista despacha una acción, el reducer calcula el estado y el store avisa a la vista. Como cada cambio es una acción registrada y cada estado es un objeto nuevo, herramientas como la extensión Redux DevTools te dejan ver el historial y volver a estados anteriores.
Úsalo cuando
Cuando mucho estado se comparte entre partes lejanas de la app, cambia desde muchos sitios y necesitas saber exactamente por qué cambió.
Ten en cuenta
El precio es la ceremonia: acciones, reducers, selectores y efectos para cada funcionalidad. En apps pequeñas, ese código extra pesa más de lo que aporta.
// Una acción describe lo que pasó
const accion = { type: "[Carrito] Producto agregado", producto: { id: 7, precio: 20 } };
// Un reducer es una función pura: mismo estado y misma acción, mismo resultado
function carritoReducer(estado = { productos: [] }, accion) {
switch (accion.type) {
case "[Carrito] Producto agregado":
return { ...estado, productos: [...estado.productos, accion.producto] };
default:
return estado; // acción desconocida: el estado no cambia
}
}NgRx
NgRx es la implementación más extendida del patrón en Angular. Su paquete clásico, @ngrx/store, sigue Redux al pie de la letra y se apoya en RxJS; el más reciente, @ngrx/signals, ofrece una alternativa más ligera basada en signals.
NgRx Store: acciones y reducers
createActionGroup declara las acciones; createReducer decide cómo cambia el estado.
- Paquete:
- @ngrx/store
- Basado en:
- RxJS
createActionGroup declara de una vez todas las acciones de una funcionalidad: le das una source y una lista de eventos, y NgRx genera un creador de acciones para cada uno, con su nombre en camelCase ("Producto agregado" pasa a ser productoAgregado).
createReducer recibe el estado inicial y una serie de on(accion, (estado, datos) => nuevoEstado). Registras el reducer con provideStore en la configuración de la app, y los componentes despachan acciones con store.dispatch(…).
Úsalo cuando
En apps grandes, con mucho estado compartido y varios equipos, donde la trazabilidad de cada cambio compensa el código extra.
Ten en cuenta
El reducer debe devolver objetos nuevos: si modificas el estado por dentro, los selectores no detectan el cambio. Y no metas en el store todo lo que ves: un formulario a medio rellenar o si un menú está abierto suelen ser estado local.
import { createActionGroup, createReducer, emptyProps, on, props } from "@ngrx/store";
export const CarritoActions = createActionGroup({
source: "Carrito",
events: {
"Producto agregado": props<{ producto: Producto }>(),
"Carrito vaciado": emptyProps(),
},
});
export const carritoReducer = createReducer(
{ productos: [] as Producto[] },
on(CarritoActions.productoAgregado, (estado, { producto }) => ({
...estado,
productos: [...estado.productos, producto],
})),
on(CarritoActions.carritoVaciado, (estado) => ({ ...estado, productos: [] })),
);
// app.config.ts: provideStore({ carrito: carritoReducer })
// componente: this.store.dispatch(CarritoActions.productoAgregado({ producto }))Selectores: createSelector y memoización
Funciones que leen y derivan datos del store, y solo recalculan si cambia su entrada.
Un selector extrae una parte del estado. Con createFeatureSelector obtienes una rama, como el carrito, y con createSelector combinas uno o varios selectores para derivar un valor, como el total.
Los selectores están memoizados: recuerdan su última entrada y su resultado y, si la entrada es la misma referencia, devuelven el resultado guardado sin recalcular. Es la misma idea que computed. En el componente los lees como observable con store.select() o como signal con store.selectSignal().
Úsalo cuando
Siempre que un componente lea del store: centralizan cómo se accede al estado y evitan que cada componente repita el mismo cálculo.
Ten en cuenta
Haz los cálculos derivados dentro de un selector, no en el componente con un map: así solo se repiten cuando cambia la entrada. Y nunca modifiques lo que devuelve un selector, porque es el propio estado del store.
import { createFeatureSelector, createSelector } from "@ngrx/store";
export const seleccionarCarrito = createFeatureSelector<EstadoCarrito>("carrito");
export const seleccionarProductos = createSelector(seleccionarCarrito, (c) => c.productos);
export const seleccionarTotal = createSelector(seleccionarProductos, (productos) =>
productos.reduce((suma, p) => suma + p.precio, 0),
);
// En un componente
export class Cabecera {
private readonly store = inject(Store);
readonly total = this.store.selectSignal(seleccionarTotal);
}Effects: los efectos secundarios, fuera del reducer
Escuchan acciones, hacen el trabajo asíncrono y responden con otras acciones.
- Paquete:
- @ngrx/effects
Un reducer debe ser puro, así que no puede llamar a una API. Para eso están los effects (@ngrx/effects): escuchan el flujo de acciones, filtran las que les interesan con ofType, hacen el trabajo asíncrono y despachan una acción nueva con el resultado, como «carga correcta» o «carga fallida».
Son flujos de RxJS, así que aquí aplicas todo lo de la lección anterior: switchMap para cargas que se pueden cancelar, concatMap para guardar en orden, exhaustMap para evitar envíos dobles y catchError dentro del observable interior.
Úsalo cuando
Para cualquier efecto secundario provocado por una acción: peticiones HTTP, navegación, notificaciones o escritura en localStorage.
Ten en cuenta
Sin catchError en el observable interior, el primer error de la API termina el effect y deja de escuchar acciones. Y no despaches desde un effect la misma acción que escucha: crearías un bucle infinito.
import { inject } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { catchError, map, of, switchMap } from "rxjs";
export const cargarProductos = createEffect(
(acciones$ = inject(Actions), api = inject(ProductosApi)) =>
acciones$.pipe(
ofType(CatalogoActions.paginaAbierta),
switchMap(() =>
api.listar().pipe(
map((productos) => CatalogoActions.cargaCorrecta({ productos })),
catchError((error) => of(CatalogoActions.cargaFallida({ mensaje: error.message }))),
),
),
),
{ functional: true },
);NgRx SignalStore
Un store ligero basado en signals: estado, valores derivados y métodos en un solo bloque.
- Paquete:
- @ngrx/signals
- Basado en:
- signals
SignalStore, del paquete @ngrx/signals, es la propuesta más reciente de NgRx. En lugar de acciones y reducers, compones el store con funciones: withState define el estado inicial, withComputed los valores derivados y withMethods las operaciones, que cambian el estado con patchState.
Cada propiedad del estado se expone como un signal, así que en la plantilla se lee como cualquier otro: carrito.total(). Puede ser global (providedIn: "root") o local a un componente si lo pones en sus providers, y tiene extensiones para colecciones de entidades o para integrar RxJS.
Úsalo cuando
Cuando quieres un estado compartido bien organizado, pero sin la ceremonia de acciones y reducers. Encaja de forma natural en el Angular basado en signals.
Ten en cuenta
Por defecto pierdes parte de la trazabilidad de Redux: los métodos cambian el estado directamente, sin una acción con nombre por cada cambio. Es una librería joven cuya API sigue creciendo, así que revisa la documentación de tu versión.
import { computed } from "@angular/core";
import { patchState, signalStore, withComputed, withMethods, withState } from "@ngrx/signals";
export const CarritoStore = signalStore(
{ providedIn: "root" },
withState({ productos: [] as Producto[] }),
withComputed(({ productos }) => ({
total: computed(() => productos().reduce((suma, p) => suma + p.precio, 0)),
})),
withMethods((store) => ({
agregar(producto: Producto) {
patchState(store, (estado) => ({ productos: [...estado.productos, producto] }));
},
vaciar() {
patchState(store, { productos: [] });
},
})),
);
// En un componente: readonly carrito = inject(CarritoStore); → {{ carrito.total() }}NGXS y la alternativa sin store
NGXS
El mismo patrón con clases y decoradores, y bastante menos código repetitivo.
- Paquete:
- @ngxs/store
- Estilo:
- clases y decoradores
NGXS sigue la idea de Redux (un store, acciones, estado inmutable) con un estilo más cercano al Angular clásico: las acciones son clases, el estado se declara con el decorador @State, cada acción se atiende en un método marcado con @Action y los selectores son métodos estáticos con @Selector.
El método de una acción recibe un contexto con getState, setState y patchState, y puede devolver un observable o una promesa. Así, llamar a una API no requiere un sistema de effects aparte.
Úsalo cuando
Si quieres un store global con acciones con nombre, pero te sobra la ceremonia de NgRx Store, o si tu equipo se siente cómodo con los decoradores.
Ten en cuenta
Su comunidad y su ecosistema son más pequeños que los de NgRx, así que encontrarás menos ejemplos y extensiones. Comprueba que su ritmo de versiones acompaña al de Angular antes de adoptarlo.
import { Injectable } from "@angular/core";
import { Action, Selector, State, StateContext } from "@ngxs/store";
export class AgregarProducto {
static readonly type = "[Carrito] Agregar producto";
constructor(public producto: Producto) {}
}
@State<EstadoCarrito>({ name: "carrito", defaults: { productos: [] } })
@Injectable()
export class CarritoState {
@Selector()
static total(estado: EstadoCarrito) {
return estado.productos.reduce((suma, p) => suma + p.precio, 0);
}
@Action(AgregarProducto)
agregar(ctx: StateContext<EstadoCarrito>, { producto }: AgregarProducto) {
ctx.patchState({ productos: [...ctx.getState().productos, producto] });
}
}
// Uso: store.dispatch(new AgregarProducto(producto)) y store.select(CarritoState.total)Un servicio con signals: cuándo no usar un store global
Para la mayoría del estado compartido, basta un servicio inyectable con signals.
Antes de añadir un store, pregúntate qué estado tienes. Mucho es local (un desplegable abierto, el texto de un campo) y debe vivir en el componente. Otra gran parte es una copia de datos del servidor, como la lista de productos, que se resuelve mejor con resource, httpResource o una petición bien hecha que con un store escrito a mano.
Lo que queda, el estado compartido de verdad, suele ser poco: el usuario conectado, el carrito, las preferencias. Para eso basta un servicio con providedIn: "root", signals privados y métodos públicos que los cambian. Es el mismo encapsulamiento que un store, sin librerías ni ceremonia, y si la app crece siempre puedes migrar.
Úsalo cuando
Como punto de partida en casi cualquier app Angular: pocas pantallas, un equipo pequeño o un estado compartido que cabe en un par de servicios.
Ten en cuenta
Expón signals de solo lectura con asReadonly(), para que nadie cambie el estado sin pasar por los métodos. Si el servicio crece hasta tener decenas de métodos que se pisan entre sí, es el momento de valorar SignalStore o NgRx.
import { Injectable, computed, signal } from "@angular/core";
@Injectable({ providedIn: "root" })
export class CarritoService {
private readonly _productos = signal<Producto[]>([]);
readonly productos = this._productos.asReadonly();
readonly total = computed(() => this._productos().reduce((suma, p) => suma + p.precio, 0));
agregar(producto: Producto) {
this._productos.update((lista) => [...lista, producto]);
}
vaciar() {
this._productos.set([]);
}
}Nota
Una regla práctica: empieza con estado local y servicios con signals (si necesitas repasar cómo funcionan los servicios, vuelve a la lección de servicios e inyección de dependencias). Añade un store cuando aparezca el problema (cambios que nadie sabe de dónde vienen, lógica duplicada entre servicios), no antes.
Interactivo
Cuatro formas de compartir estado
Activa o desactiva opciones para compararlas cara a cara.
| Característica | NgRx Store | NgRx SignalStore | NGXS |
|---|---|---|---|
| Cómo se cambia el estado | Acciones y reducers puros | Métodos que llaman a patchState | Acciones como clases y métodos con @Action |
| Código por funcionalidad | Mucho: acciones, reducer, selectores y effects | Poco: un bloque por store | Medio |
| Trazabilidad | Máxima: cada cambio es una acción visible en DevTools | Media: por defecto, sin acciones con nombre | Alta: acciones con nombre |
| Trabajo asíncrono | Effects con RxJS | Métodos asíncronos o integración con RxJS | En el propio método de la acción |
| Basado en | RxJS, con lectura también como signals | Signals | RxJS y decoradores |
| Encaja con | Apps grandes y varios equipos | Estado por funcionalidad en apps medianas | Equipos que quieren Redux con menos ceremonia |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Una app de banca con decenas de pantallas y cuatro equipos. Auditoría exige saber qué acción provocó cada cambio de estado y poder reproducir los errores paso a paso.
Para recordar
- El patrón Redux centraliza el estado: los componentes despachan acciones y reducers puros calculan el estado nuevo, en un flujo unidireccional.
- En NgRx Store,
createActionGroupycreateReducerdefinen los cambios, los selectores memoizados leen y derivan datos y los effects hacen el trabajo asíncrono. - SignalStore ofrece estado compartido basado en signals con mucho menos código; NGXS, el patrón Redux con clases y decoradores.
- La mayoría del estado es local o una copia de datos del servidor: para lo poco que se comparte, a menudo basta un servicio con signals.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.En el patrón Redux, ¿qué hace un reducer?
2.¿Por qué un selector creado con
createSelectorno recalcula si nada ha cambiado?3.En NgRx Store, ¿dónde debe ir una llamada HTTP provocada por una acción?
4.¿Qué diferencia principal hay entre NgRx Store y NgRx SignalStore?
5.Tu app tiene cinco pantallas y solo comparte el usuario conectado. ¿Qué es lo más razonable?