Módulo 19: Gestión del estado
Objetivos del módulo
- Distinguir estado local, compartido, global, de servidor, de URL y de formulario, y decidir dónde vive cada dato
- Construir un servicio con señales que exponga solo lectura y un flujo en un sentido
- Controlar el alcance de un estado con
providers, y persistirlo coneffect - Instalar la versión de NgRx que corresponde a tu versión de Angular
- Crear un NgRx SignalStore con
signalStore,withState,withComputed,withMethodsywithHooks - Cambiar el estado con
patchState, y protegerlo conprotectedState - Tratar peticiones con
rxMethodytapResponse, y sin RxJS consignalMethod - Gestionar colecciones con
withEntities, sus actualizadores y las colecciones con nombre - Combinar SignalStore con
httpResourcemediantewithProps, y usarwithLinkedState - Reutilizar lógica con
signalStoreFeature - Entender el ciclo de NgRx Store: acciones, reducers, selectores y efectos
- Conocer el complemento Events de SignalStore
- Probar un store, reconocer los errores más frecuentes y elegir la herramienta adecuada
📘 Concepto: Este módulo usa la API EMPRESA. Descárgala de
recursos/empresa-api.zip, arráncala connode server.mjsy quedará enhttp://localhost:3000/api. Los datos están en memoria: al reiniciarla vuelven a su estado inicial.
1. Qué es el estado y por qué se complica
El estado son los datos que cambian mientras la aplicación se ejecuta y que afectan a lo que se ve: el usuario conectado, las líneas del carrito, el texto de un buscador, si un desplegable está abierto. La vista es, en el fondo, una función de ese estado. Si el estado es correcto y la vista se deriva de él, la pantalla será correcta.
Mientras cada dato lo usa un solo componente, no hay nada que gestionar: basta una señal, como viste en el módulo 05. Los problemas aparecen cuando un dato lo necesitan varios sitios:
- Copias que se desincronizan. La cabecera guarda el número de artículos y el carrito guarda sus líneas. Si alguien olvida actualizar uno de los dos, la pantalla se contradice.
- Paso de datos en cascada. Un valor baja por cinco niveles de
input()y los eventos suben por cinco niveles deoutput(), aunque los componentes intermedios no lo usen. En inglés se le llama prop drilling. - Cambios desde cualquier sitio. Si cinco componentes modifican el mismo dato, cuando sale mal no sabes quién ha sido.
La idea central de este módulo se resume en tres reglas, que valen para cualquier herramienta:
- Una sola fuente de verdad. Cada dato se guarda en un único sitio. Lo demás son lecturas o cálculos.
- Lo derivado no se guarda. Si el total se puede calcular a partir de las líneas, es un
computed, nunca otra señal que haya que mantener. - Flujo en un sentido. Los datos bajan hacia la vista. Los cambios suben como peticiones a quien es dueño del dato, y solo él escribe.
Figura 1. Dónde debe vivir cada dato, según quién lo necesita y de qué tipo es.
| Tipo de estado | Ejemplo | Dónde vive |
|---|---|---|
| Local | Pestaña activa, panel abierto | Señal en el componente |
| Compartido | Filtros de una página | Servicio o store en providers del componente |
| Global | Sesión, carrito, tema | Servicio o store en la raíz |
| De servidor | Lista de empleados | httpResource o un store que la cachea |
| De URL | Página actual, id | Parámetros del router |
| De formulario | Borrador sin guardar | Signal Forms o formularios reactivos |
⚠️ Importante: El error más común no es elegir mal la biblioteca, sino guardar demasiado. Antes de añadir un dato a un store, pregúntate si puedes derivarlo, si ya está en la URL o si en realidad es una copia del servidor que
httpResourcepuede traer cuando haga falta.
2. Empezar por lo simple: subir el estado
Si dos componentes hermanos necesitan un dato, la primera solución no requiere nada nuevo: el padre común es el dueño, se lo pasa con input() y recibe los cambios con output(). Es el patrón contenedor y presentación del módulo 06.
@Component({
selector: 'app-pagina',
imports: [Buscador, Resultados],
template: `
<app-buscador [texto]="texto()" (textoChange)="texto.set($event)" />
<app-resultados [texto]="texto()" />
`,
})
export class Pagina {
protected readonly texto = signal('');
}
Esta solución es buena: el flujo se ve en la plantilla y los hijos no dependen de nada externo. Solo deja de compensar cuando los componentes están lejos en el árbol, o cuando los intermedios cargan con datos que no les importan. Ese es el momento de pasar a un servicio.
3. Servicios con señales
En el módulo 08 viste que un servicio es una clase que el inyector crea y comparte. Si le pones señales dentro, tienes un almacén de estado completo, sin ninguna biblioteca: es reactivo, tipado y fácil de probar. Para muchas aplicaciones es todo lo que necesitas.
Un carrito compartido 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de
@Service(), escribe@Injectable({ providedIn: 'root' }), que hace lo mismo; todo lo demás de esta sección funciona igual.
// src/app/carrito/carrito-estado.ts
import { computed, effect, Service, signal } from '@angular/core';
import { Producto } from '../modelos';
export interface LineaCarrito {
productoId: number;
nombre: string;
precio: number;
cantidad: number;
}
const CLAVE = 'empresa.carrito';
function leerGuardado(): LineaCarrito[] {
const texto = localStorage.getItem(CLAVE);
return texto ? (JSON.parse(texto) as LineaCarrito[]) : [];
}
@Service()
export class CarritoEstado {
// Estado privado y escribible: solo esta clase puede cambiarlo
private readonly _lineas = signal<LineaCarrito[]>(leerGuardado());
// Lo que ve el resto de la aplicación: de solo lectura
readonly lineas = this._lineas.asReadonly();
// Estado derivado: nunca se guarda, se calcula
readonly unidades = computed(() =>
this._lineas().reduce((suma, linea) => suma + linea.cantidad, 0),
);
readonly total = computed(() =>
this._lineas().reduce((suma, linea) => suma + linea.precio * linea.cantidad, 0),
);
readonly vacio = computed(() => this._lineas().length === 0);
constructor() {
// Efecto de salida: cada cambio se copia en localStorage
effect(() => localStorage.setItem(CLAVE, JSON.stringify(this._lineas())));
}
anadir(producto: Producto) {
this._lineas.update((lineas) => {
const existe = lineas.some((l) => l.productoId === producto.productoId);
return existe
? lineas.map((l) =>
l.productoId === producto.productoId ? { ...l, cantidad: l.cantidad + 1 } : l,
)
: [
...lineas,
{
productoId: producto.productoId,
nombre: producto.nombre,
precio: producto.precio,
cantidad: 1,
},
];
});
}
quitar(productoId: number) {
this._lineas.update((lineas) => lineas.filter((l) => l.productoId !== productoId));
}
vaciar() {
this._lineas.set([]);
}
}
Hay cuatro decisiones en este código que conviene entender:
_lineases privada ylineases de solo lectura.asReadonly()devuelve una señal sinsetniupdate. Un componente no puede escribircarrito.lineas.set([]), porque no compila. Todo cambio pasa por un método con nombre.unidades,totalyvaciosoncomputed. Si fueran señales, cada método debería recordar actualizarlas. Así es imposible que se contradigan con las líneas.updatecrea un array nuevo.map,filtery el operador...devuelven objetos nuevos. Si hicieraslineas.push(...)y devolvieras el mismo array, la señal compararía conObject.is, vería el mismo objeto y no avisaría a nadie. Lo viste en el módulo 05.- El
effectsincroniza hacia fuera. Un efecto sirve para llevar el estado a algo que no es Angular, comolocalStorage. No lo uses para calcular un dato a partir de otro: para eso estácomputed.
Los componentes inyectan el servicio y no se conocen entre sí:
// src/app/carrito/tienda.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, computed, inject } from '@angular/core';
import { API, Pagina, Producto } from '../modelos';
import { CarritoEstado } from './carrito-estado';
@Component({
selector: 'app-catalogo',
imports: [CurrencyPipe],
template: `
<h2>Catálogo</h2>
<ul class="productos">
@for (producto of productos(); track producto.productoId) {
<li>
<span>{{ producto.nombre }}</span>
<strong>{{ producto.precio | currency: 'EUR' }}</strong>
<button type="button" (click)="carrito.anadir(producto)">Añadir</button>
</li>
}
</ul>
`,
styleUrl: './tienda.css',
})
export class Catalogo {
protected readonly carrito = inject(CarritoEstado);
private readonly recurso = httpResource<Pagina<Producto>>(() => `${API}/productos?tamano=5`);
protected readonly productos = computed(() =>
this.recurso.hasValue() ? this.recurso.value().contenido : [],
);
}
@Component({
selector: 'app-resumen-carrito',
imports: [CurrencyPipe],
template: `
<h2>Carrito ({{ carrito.unidades() }})</h2>
@if (carrito.vacio()) {
<p>Todavía no has añadido nada.</p>
} @else {
<ul class="lineas">
@for (linea of carrito.lineas(); track linea.productoId) {
<li>
<span>{{ linea.cantidad }} × {{ linea.nombre }}</span>
<button type="button" (click)="carrito.quitar(linea.productoId)">Quitar</button>
</li>
}
</ul>
<p class="total">Total: {{ carrito.total() | currency: 'EUR' }}</p>
<button type="button" (click)="carrito.vaciar()">Vaciar</button>
}
`,
styleUrl: './tienda.css',
})
export class ResumenCarrito {
protected readonly carrito = inject(CarritoEstado);
}
@Component({
selector: 'app-tienda',
imports: [Catalogo, ResumenCarrito],
template: `
<div class="tienda">
<app-catalogo />
<app-resumen-carrito />
</div>
`,
styleUrl: './tienda.css',
})
export class Tienda {}
API y los tipos Pagina y Producto están en un fichero modelos.ts, como en los módulos anteriores. API vale http://localhost:3000/api.

Figura 2. Catalogo y ResumenCarrito no se pasan nada: comparten CarritoEstado. Al recargar la página, el carrito sigue ahí gracias a localStorage.
Figura 3. El flujo en un sentido: los componentes piden cambios con métodos y reciben los resultados por señales.
💡 Consejo: Como estas señales avisan a la vista, el componente funciona igual con la detección de cambios por defecto de Angular 22 (OnPush) que con
Eager. Tienes el motivo en el módulo 09.
Estado del servidor en un servicio 🔄 v22.0
🔄 Cambia en Angular 22.0.
httpResourcepasa de experimental a estable. En la LTS (Angular 21) es experimental. Si usas la LTS: este código compila igual si cambias@Service()por@Injectable({ providedIn: 'root' }), perohttpResourcepuede cambiar entre versiones.
Un servicio también puede encargarse de datos remotos. Lo más cómodo es meter un httpResource dentro y exponer señales ya preparadas. Repasa el módulo 13 si no recuerdas cómo funciona.
// src/app/servicios.ts
@Service()
export class DepartamentoEstado {
readonly deptId = signal(10);
private readonly recurso = httpResource<Pagina<Empleado>>(() => ({
url: `${API}/empleados`,
params: { deptId: this.deptId(), tamano: 100 },
}));
readonly empleados = computed(() =>
this.recurso.hasValue() ? this.recurso.value().contenido : [],
);
readonly masaSalarial = computed(() =>
this.empleados().reduce((suma, e) => suma + e.salario, 0),
);
readonly cargando = this.recurso.isLoading;
readonly error = computed(() => this.recurso.error()?.message ?? null);
recargar() {
this.recurso.reload();
}
}
Basta con hacer estado.deptId.set(30) para que se cancele la petición anterior, llegue la nueva y se recalcule la masa salarial. Fíjate en lo que no hay: ni subscribe, ni señales cargando manuales, ni copias de la lista. El servicio no guarda los empleados: el recurso es su dueño.
Alcance: quién comparte la instancia 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de
@Service({ autoProvided: false }), escribe@Injectable()sin opciones y ponlo enproviders; el comportamiento es el mismo.
Lo que hace que un estado sea global no es el servicio, sino dónde se proporciona. Con @Service(), el inyector raíz crea una sola instancia para toda la aplicación, y vive hasta que se cierra la pestaña. Si lo pones en providers de un componente, cada aparición de ese componente tiene su instancia, que comparte con sus hijos y que se destruye con él.
@Service({ autoProvided: false })
export class FiltroEstado {
readonly texto = signal('');
}
@Component({
selector: 'app-caja-filtro',
template: `
<input aria-label="Filtro" [value]="filtro.texto()" (input)="cambiar($event)" />
`,
})
export class CajaFiltro {
protected readonly filtro = inject(FiltroEstado);
protected cambiar(evento: Event) {
this.filtro.texto.set((evento.target as HTMLInputElement).value);
}
}
@Component({
selector: 'app-lista-filtrada',
imports: [CajaFiltro],
providers: [FiltroEstado], // una instancia por cada <app-lista-filtrada>
template: `
<app-caja-filtro />
<p>Buscando: {{ filtro.texto() }}</p>
`,
})
export class ListaFiltrada {
readonly filtro = inject(FiltroEstado);
}
Con dos <app-lista-filtrada> en la misma página, escribir ana en la primera deja vacía la segunda. Lo hemos comprobado: la primera muestra Buscando: ana, la segunda Buscando:, y las dos instancias son objetos distintos.
| Dónde se proporciona | Instancias | Vida | Uso |
|---|---|---|---|
@Service() | Una | Toda la aplicación | Sesión, carrito, preferencias |
providers de una ruta | Una por configuración de ruta | Depende de la ruta | Estado de una función |
providers de un componente | Una por componente | La del componente | Filtros y estado de una pantalla |
⚠️ Importante: El estado en la raíz no se limpia solo. Si un usuario cierra sesión y entra otro, las señales globales conservan los datos del anterior. Vacíalas tú en el cierre de sesión, o usa estado ligado a un componente cuando no haga falta que sobreviva.
Cuándo se queda corto
Un servicio con señales cubre muy bien la mayoría de casos. Empieza a pesar cuando:
- Repites en cada servicio la misma pareja de
cargandoyerror, y el mismoupdatepara buscar un elemento en un array. - Quieres componer comportamientos entre varios estados sin herencia de clases.
- Tu equipo necesita una convención común para que todos los servicios se parezcan.
Esos tres problemas son los que resuelve NgRx.
4. Instalar NgRx: la versión importa 🔄 v22.0
🔄 Cambia en Angular 22.0. NgRx 22 exige Angular 22 (su dependencia es
"@angular/core": "^22.0.0"), así que no se instala en la LTS (Angular 21). Si usas la LTS: instala NgRx 21, cuya última versión es la 21.1.1 y exige^21.0.0, por ejemplo connpm install @ngrx/signals@21.
NgRx no es parte de Angular. Es un conjunto de bibliotecas que publica su propia comunidad, y sus números de versión siguen a los de Angular: cada mayor de NgRx exige la misma mayor de Angular. Si intentas instalar la equivocada, npm se queja con un error ERESOLVE.
| Tu Angular | NgRx | Orden de instalación |
|---|---|---|
| 22.x | 22.0.1 | npm install @ngrx/signals @ngrx/operators |
| 21.x (LTS) | 21.1.1 | npm install @ngrx/signals@21 @ngrx/operators@21 |
Hemos comprobado las dependencias en npm y, además, hemos compilado los ejemplos de este módulo con Angular 21.2 y NgRx 21.1.1. Compilan todos, con los cambios que indican los apartados marcados. Entre NgRx 21.1.1 y 22.0.1 las entidades y los eventos de SignalStore no cambian: las diferencias que afectan a este módulo son @ngrx/signals/resource, el tipo SignalStoreFeatureType y tapResponse, que en NgRx 22 pierde su forma antigua.
| Paquete | Para qué sirve | Se usa en |
|---|---|---|
@ngrx/signals | SignalStore, entidades, rxMethod, eventos | Secciones 5 a 10 |
@ngrx/operators | tapResponse, mapResponse, concatLatestFrom | Secciones 6 y 12 |
@ngrx/store | Store clásico | Sección 12 |
@ngrx/effects | Efectos del Store clásico | Sección 12 |
@ngrx/entity | Adaptador de entidades del Store clásico | Sección 12 |
@ngrx/store-devtools | Conexión con Redux DevTools | Sección 12 |
💡 Consejo:
ng add @ngrx/signalstambién lo instala, y escoge la versión compatible con tu proyecto.@ngrx/signalsno depende de@ngrx/store: usar SignalStore no te obliga a cargar el Store clásico.
5. NgRx SignalStore
SignalStore es un almacén basado en señales que se construye por composición de funciones. No lo escribes como una clase con métodos, sino como una lista de capas. Cada una añade algo: estado, cálculos, métodos o ciclo de vida.
Vamos a rehacer el carrito para compararlo con el servicio. Añadimos un cupón de descuento para que haya algo más de estado.
// src/app/carrito/carrito.store.ts
import { computed } from '@angular/core';
import {
patchState,
signalStore,
watchState,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { Producto } from '../modelos';
import { LineaCarrito } from './carrito-estado';
interface EstadoCarrito {
lineas: LineaCarrito[];
cupon: string | null;
}
const CLAVE = 'empresa.carrito.store';
export const CarritoStore = signalStore(
{ providedIn: 'root' },
// 1. Estado inicial: cada propiedad se convierte en una señal
withState<EstadoCarrito>({ lineas: [], cupon: null }),
// 2. Estado derivado: ya puede leer las señales del paso anterior
withComputed(({ lineas, cupon }) => {
const subtotal = computed(() => lineas().reduce((s, l) => s + l.precio * l.cantidad, 0));
return {
subtotal,
unidades: computed(() => lineas().reduce((s, l) => s + l.cantidad, 0)),
vacio: computed(() => lineas().length === 0),
total: computed(() => (cupon() === 'EMPRESA10' ? subtotal() * 0.9 : subtotal())),
};
}),
// 3. Métodos: el único sitio donde se cambia el estado
withMethods((store) => ({
anadir(producto: Producto) {
patchState(store, ({ lineas }) => {
const existe = lineas.some((l) => l.productoId === producto.productoId);
return {
lineas: existe
? lineas.map((l) =>
l.productoId === producto.productoId ? { ...l, cantidad: l.cantidad + 1 } : l,
)
: [
...lineas,
{
productoId: producto.productoId,
nombre: producto.nombre,
precio: producto.precio,
cantidad: 1,
},
],
};
});
},
quitar(productoId: number) {
patchState(store, ({ lineas }) => ({
lineas: lineas.filter((l) => l.productoId !== productoId),
}));
},
aplicarCupon(cupon: string) {
patchState(store, { cupon });
},
vaciar() {
patchState(store, { lineas: [], cupon: null });
},
})),
// 4. Ciclo de vida: se ejecuta al crear y al destruir el store
withHooks({
onInit(store) {
const guardado = localStorage.getItem(CLAVE);
if (guardado) {
patchState(store, JSON.parse(guardado) as EstadoCarrito);
}
watchState(store, (estado) => localStorage.setItem(CLAVE, JSON.stringify(estado)));
},
}),
);
signalStore devuelve una clase. Por eso CarritoStore empieza por mayúscula, y se usa como cualquier servicio:
export class ResumenCarrito {
protected readonly carrito = inject(CarritoStore);
}
<h2>Carrito ({{ carrito.unidades() }})</h2>
<p>{{ carrito.total() | currency: 'EUR' }}</p>
<button type="button" (click)="carrito.aplicarCupon('EMPRESA10')">Aplicar cupón</button>
La plantilla es la misma que con el servicio. El componente no nota la diferencia: lee señales y llama a métodos.
Figura 4. Cada capa recibe lo que han añadido las anteriores. Por eso importa el orden.
Las capas, una a una
| Función | Qué añade | Qué recibe |
|---|---|---|
withState | Una señal por cada propiedad | Objeto inicial o función que lo crea |
withComputed | Señales derivadas | Lo anterior, y devuelve señales |
withMethods | Métodos públicos | Lo anterior; es un contexto de inyección |
withHooks | onInit y onDestroy | Lo anterior |
withProps | Propiedades que no son estado | Lo anterior |
withLinkedState | Estado que se reinicia según otras señales | Lo anterior |
withFeature | Una función de capas con acceso a los miembros | Lo anterior |
El orden tiene una consecuencia práctica. Este código no compila, porque withComputed todavía no conoce total:
export const OrdenMal = signalStore(
withComputed(({ total }) => ({ doble: computed(() => total() * 2) })),
withState({ total: 0 }),
);
error TS2339: Property 'total' does not exist on type '{}'.
patchState: cómo se escribe
patchState es la única forma de cambiar el estado. Admite uno o varios actualizadores, que pueden ser objetos parciales o funciones. Lo que no menciones se conserva.
patchState(store, { cupon: 'EMPRESA10' }); // objeto parcial
patchState(store, ({ lineas }) => ({ lineas: lineas.slice(1) })); // función sobre el estado actual
patchState(store, { cupon: null }, ({ lineas }) => ({ lineas: [] })); // varios seguidos
Una costumbre muy útil es sacar las funciones fuera del store, porque se leen mejor y se reutilizan:
function sinLinea(productoId: number): PartialStateUpdater<EstadoCarrito> {
return ({ lineas }) => ({ lineas: lineas.filter((l) => l.productoId !== productoId) });
}
patchState(store, sinLinea(5));
⚠️ Importante:
patchStatehace una copia superficial. Si el estado tiene objetos anidados, debes crear los objetos nuevos tú mismo con.... Y NgRx no congela el estado de SignalStore: en nuestra prueba, modificar directamentecarrito.lineas()[0].cantidad = 99no da ningún error, pero tampoco avisa a nadie, de modo que la vista se queda desfasada sin ningún mensaje. Crea siempre objetos nuevos.
Estado protegido
Por defecto, un SignalStore no se puede escribir desde fuera. Si llamas a patchState en un componente, TypeScript te detiene:
const carrito = inject(CarritoStore);
patchState(carrito, { cupon: 'X' });
error TS2345: Argument of type '{ lineas: Signal<LineaCarrito[]>; cupon: Signal<string | null>; ...
Type 'Signal<LineaCarrito[]>' is missing the following properties from type
'WritableSignal<LineaCarrito[]>': set, update, asReadonly, [ɵWRITABLE_SIGNAL]
Es el mismo efecto que asReadonly(), pero sin escribirlo. El estado solo cambia desde los withMethods del store. Si alguna vez necesitas lo contrario, existe la opción protectedState: false, aunque rara vez conviene:
export const Abierto = signalStore(
{ protectedState: false },
withState({ cupon: null as string | null }),
);
Leer el estado completo
getState(store) devuelve una instantánea del estado, sin los computed ni los métodos. watchState(store, fn) llama a fn con cada cambio, y es lo que usamos para guardar en localStorage. Esta es la salida real de getState con un producto y el cupón:
{"lineas":[{"productoId":1,"nombre":"P1","precio":100,"cantidad":1}],"cupon":"EMPRESA10"}
📘 Concepto: Diferencia entre
watchStateyeffect: uneffectse ejecuta más tarde y agrupa varios cambios en una sola ejecución.watchStatese ejecuta una vez al registrarlo y luego, de forma síncrona, con cadapatchState. Por eso sirve para registrar o persistir cada cambio.
Señales anidadas: DeepSignal
Cuando una propiedad del estado es un objeto, SignalStore te deja leer sus campos como señales, sin escribir ningún computed. Lo mismo hace signalState, la versión ligera para usar dentro de un componente:
const estado = signalState({
usuario: { nombre: 'Ana', direccion: { ciudad: 'Madrid' } },
visitas: 0,
});
estado.usuario.direccion.ciudad(); // 'Madrid'
patchState(estado, (e) => ({ usuario: { ...e.usuario, nombre: 'Luis' } }));
estado.usuario.nombre(); // 'Luis'
No se crean señales para los arrays ni para las propiedades que no sean objetos planos.
Global o por componente
La opción { providedIn: 'root' } hace que el store sea global. Si la omites, es un servicio más que debes poner en providers, igual que FiltroEstado:
@Component({
selector: 'app-empleados-pagina',
providers: [EmpleadosStore],
// ...
})
export class EmpleadosPagina {
protected readonly store = inject(EmpleadosStore);
}
Cuando se destruye el componente, se destruye su store: se ejecuta onDestroy y se cancelan las peticiones que tenga en marcha. El estado de una pantalla debería vivir así, salvo que tenga que sobrevivir a la navegación.
6. Peticiones con rxMethod
Un método de store puede ser async y usar firstValueFrom, pero así no controlas qué pasa si se lanza dos veces seguidas. rxMethod crea un método cuyo cuerpo es una tubería RxJS, de modo que puedes usar switchMap, debounceTime y los demás operadores del módulo 14.
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
Lo mejor de rxMethod es lo que acepta al llamarlo. Aquí buscar es de tipo rxMethod<string>:
| Llamada | Efecto |
|---|---|
store.buscar('ana') | Se ejecuta una vez con ese valor |
store.buscar(texto) con una señal | Se ejecuta ahora y cada vez que cambie la señal |
store.buscar(() => texto().trim()) | Igual, con una función reactiva |
store.buscar(texto$) con un Observable | Se ejecuta con cada emisión |
Ejemplo real: una búsqueda de empleados con retardo, y con estado de carga y error.
// src/app/empleados/empleados.store.ts (fragmento)
withMethods((store, http = inject(HttpClient)) => ({
filtrar(texto: string) {
patchState(store, { texto });
},
// Búsqueda con retardo: switchMap cancela la petición anterior
buscar: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, enCurso())),
switchMap((texto) =>
http
.get<Pagina<Empleado>>(`${API}/empleados`, {
params: { q: texto, tamano: 6, orden: '-salario' },
})
.pipe(
tapResponse({
next: (pagina) =>
patchState(
store,
setAllEntities(pagina.contenido, configEmpleado),
terminada(),
),
error: (e: HttpErrorResponse) => patchState(store, fallida(e.message)),
}),
),
),
),
),
})),
withHooks({
onInit(store) {
// Le pasamos la señal: rxMethod se suscribe a ella
store.buscar(store.texto);
},
}),
El componente solo llama a store.filtrar(...). La cadena es: cambia la señal texto, rxMethod recibe el valor, espera 300 ms, cancela lo anterior y pide la lista. En la sección 7 verás el fichero completo.
Como withMethods se ejecuta dentro de un contexto de inyección, sus parámetros con valor por defecto pueden usar inject. Es el patrón habitual: (store, http = inject(HttpClient)) => ....
El error que mata la tubería
Este es el fallo más traicionero de rxMethod. Si un error llega a la tubería principal, el Observable termina, y el método deja de funcionar para siempre, sin avisar en las llamadas siguientes:
mal: rxMethod<number>(
pipe(
tap((n) => vistos.push(n)),
switchMap((n) => (n === 2 ? throwError(() => new Error('fallo de red')) : of(n))),
),
),
Llamando a mal(1), mal(2), mal(3) y mal(4), el array vistos queda así:
1,2
Las llamadas 3 y 4 se pierden sin ningún error. El fallo solo llega al gestor global de RxJS. La solución es tratar el error dentro del switchMap, en el Observable interno, para que la tubería exterior siga viva. Para eso sirve tapResponse: exige que escribas next y error, y atrapa el error para que no se propague.
⚠️ Importante:
tapResponsesiempre va dentro del operador de aplanado (switchMap,concatMap…), no después. Fuera de él, la tubería ya habría terminado. En NgRx 22 solo admite el objeto{ next, error }. La forma antigua, con funciones sueltas, ya estaba obsoleta en NgRx 21 y ha desaparecido.
Qué operador elegir
El operador decide qué pasa si llega una petición nueva mientras otra sigue en marcha. Es la misma tabla del módulo 14, aplicada al estado:
| Operador | Comportamiento | Úsalo para |
|---|---|---|
switchMap | Cancela la anterior | Búsquedas, lecturas |
concatMap | Encola, en orden | Escrituras que deben respetar el orden |
mergeMap | Todas a la vez | Peticiones independientes, como borrar varios |
exhaustMap | Ignora las nuevas hasta acabar | Enviar un formulario, cargar una vez |
signalMethod: lo mismo sin RxJS
Si solo quieres ejecutar algo cada vez que cambia una señal, sin operadores, signalMethod es más sencillo. Se importa de @ngrx/signals:
const registrar = signalMethod<number>((valor) => vistos.push(valor));
registrar(numero); // numero es una señal
La diferencia está en el momento. signalMethod se apoya en un effect, así que si la señal cambia dos veces seguidas solo ve el último valor. Con la señal en 1 y haciendo set(2) y set(3) seguidos, recibió:
1,3
rxMethod | signalMethod | |
|---|---|---|
| Importar | @ngrx/signals/rxjs-interop | @ngrx/signals |
| Cuerpo | Tubería RxJS | Función normal |
| Acepta Observable | Sí | No |
| Retardo, cancelación | Con operadores | Tienes que hacerlo a mano |
| Uso típico | Peticiones HTTP | Reaccionar a una señal con efectos sencillos |
7. Colecciones con withEntities
Casi todo el estado real son listas de cosas con identificador. Con un array, modificar un elemento exige recorrerlo, y hay que escribir ese map una y otra vez. withEntities guarda la colección de forma normalizada, como una tabla con índice de una base de datos.
Figura 5. ids guarda el orden, entityMap guarda los datos y entities los reúne para la plantilla.
| Señal | Tipo | Contenido |
|---|---|---|
ids | EntityId[] | Identificadores, en orden |
entityMap | Record<EntityId, Entidad> | Cada entidad por su identificador |
entities | Entidad[] | computed: el array listo para usar |
Por defecto, withEntities<T>() espera que cada entidad tenga una propiedad id. Los datos de EMPRESA usan empId, deptId, etc., así que hay que indicar cómo se obtiene. Se hace con entityConfig, y esa configuración se pasa también a cada actualizador:
const configEmpleado = entityConfig({
entity: type<Empleado>(),
selectId: (empleado) => empleado.empId,
});
export const EmpleadosStore = signalStore(
withEntities(configEmpleado),
// ...
);
patchState(store, setAllEntities(lista, configEmpleado));
Si te olvidas de la configuración, el error de compilación te lo dice, aunque de forma larga:
error TS2345: Argument of type 'Empleado[]' is not assignable to parameter of type '{ id: EntityId; }[]'.
Ojo: withEntities<Empleado>() compila aunque Empleado no tenga id. El fallo aparece después, al usar el actualizador.
Los actualizadores
Se importan de @ngrx/signals/entities y se pasan a patchState.
| Actualizador | Qué hace | Si ya existe |
|---|---|---|
addEntity, addEntities | Añade al final | No lo cambia |
prependEntity, prependEntities | Añade al principio | No lo cambia |
setEntity, setEntities | Pone el elemento | Lo sustituye |
upsertEntity, upsertEntities | Pone el elemento | Lo fusiona |
setAllEntities | Sustituye la colección | Todo nuevo |
updateEntity, updateEntities | Modifica por id o por predicado | Solo cambia lo indicado |
updateAllEntities | Modifica todas | Solo cambia lo indicado |
removeEntity, removeEntities | Quita por id o por predicado | |
removeAllEntities | Vacía la colección |
En changes puedes dar un objeto parcial o una función:
patchState(store, updateEntity({ id: 100, changes: { salario: 7000 } }, configEmpleado));
patchState(
store,
updateEntity(
{ id: 100, changes: (e) => ({ salario: e.salario + 100 }) },
configEmpleado,
),
);
patchState(store, removeEntities((e: Empleado) => e.deptId === 40));
⚠️ Importante: Al añadir literales, indica el tipo:
addEntity<Tarea>({ id: 1, titulo, hecha: false }). Sin él, TypeScript deducehecha: falsecomo tipo literal y falla conType 'boolean' is not assignable to type 'false'.
Una página completa de empleados
Antes de escribir el store, creamos la pieza que repetiremos en todos: el estado de una petición. Es una función de capas, que estudiarás en la sección 9.
// src/app/compartido/con-peticion.ts
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
type Peticion = 'inactiva' | 'en-curso' | 'terminada' | { error: string };
export function withPeticion() {
return signalStoreFeature(
withState<{ peticion: Peticion }>({ peticion: 'inactiva' }),
withComputed(({ peticion }) => ({
cargando: computed(() => peticion() === 'en-curso'),
terminada: computed(() => peticion() === 'terminada'),
error: computed(() => {
const valor = peticion();
return typeof valor === 'object' ? valor.error : null;
}),
})),
);
}
// Actualizadores: objetos que se pasan a patchState
export const enCurso = () => ({ peticion: 'en-curso' as Peticion });
export const terminada = () => ({ peticion: 'terminada' as Peticion });
export const fallida = (error: string) => ({ peticion: { error } as Peticion });
Las escrituras de la API exigen sesión, y los borrados exigen el rol ADMIN. Guardamos la sesión en un store global, y un interceptor funcional, como los del módulo 12, añade el token:
// src/app/sesion/sesion.store.ts
import { HttpClient, HttpInterceptorFn } from '@angular/common/http';
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap } from 'rxjs';
import { API } from '../modelos';
interface RespuestaLogin {
token: string;
usuario: { email: string; rol: string };
}
export const SesionStore = signalStore(
{ providedIn: 'root' },
withState({ token: null as string | null, email: null as string | null }),
withComputed(({ token }) => ({
conectado: computed(() => token() !== null),
})),
withMethods((store, http = inject(HttpClient)) => ({
entrar: rxMethod<{ email: string; password: string }>(
pipe(
switchMap((credenciales) =>
http.post<RespuestaLogin>(`${API}/auth/login`, credenciales).pipe(
tapResponse({
next: ({ token, usuario }) => patchState(store, { token, email: usuario.email }),
error: () => patchState(store, { token: null, email: null }),
}),
),
),
),
),
salir() {
patchState(store, { token: null, email: null });
},
})),
);
export const conToken: HttpInterceptorFn = (peticion, siguiente) => {
const token = inject(SesionStore).token();
return siguiente(
token ? peticion.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : peticion,
);
};
// src/app/app.config.ts (fragmento)
provideHttpClient(withInterceptors([conToken])),
Y este es el store de la página. El borrado es optimista: quita el empleado de la lista antes de que responda la API, para que la pantalla reaccione al instante, y lo repone si la API lo rechaza.
// src/app/empleados/empleados.store.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import {
patchState,
signalStore,
type,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import {
entityConfig,
removeEntity,
setAllEntities,
setEntity,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { debounceTime, distinctUntilChanged, pipe, switchMap, tap } from 'rxjs';
import { enCurso, fallida, terminada, withPeticion } from '../compartido/con-peticion';
import { API, Empleado, Pagina } from '../modelos';
const configEmpleado = entityConfig({
entity: type<Empleado>(),
selectId: (empleado) => empleado.empId,
});
export const EmpleadosStore = signalStore(
withEntities(configEmpleado),
withState({ texto: '' }),
withPeticion(),
withComputed(({ entities }) => ({
masaSalarial: computed(() => entities().reduce((suma, e) => suma + e.salario, 0)),
})),
withMethods((store, http = inject(HttpClient)) => ({
filtrar(texto: string) {
patchState(store, { texto });
},
// Búsqueda con retardo: switchMap cancela la petición anterior
buscar: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, enCurso())),
switchMap((texto) =>
http
.get<Pagina<Empleado>>(`${API}/empleados`, {
params: { q: texto, tamano: 6, orden: '-salario' },
})
.pipe(
tapResponse({
next: (pagina) =>
patchState(
store,
setAllEntities(pagina.contenido, configEmpleado),
terminada(),
),
error: (e: HttpErrorResponse) => patchState(store, fallida(e.message)),
}),
),
),
),
),
// Borrado optimista: se quita ya, y se repone si la API lo rechaza
eliminar(id: number) {
const copia = store.entityMap()[id];
patchState(store, removeEntity(id));
http.delete(`${API}/empleados/${id}`).subscribe({
error: (e: HttpErrorResponse) =>
patchState(
store,
setEntity(copia, configEmpleado),
fallida(e.error?.mensaje ?? e.message),
),
});
},
subirSalario(id: number, porcentaje: number) {
patchState(
store,
updateEntity(
{ id, changes: (e) => ({ salario: Math.round(e.salario * (1 + porcentaje / 100)) }) },
configEmpleado,
),
);
},
})),
withHooks({
onInit(store) {
// Le pasamos la señal: rxMethod se suscribe a ella
store.buscar(store.texto);
},
}),
);
// src/app/empleados/empleados-pagina.ts (sin estilos)
@Component({
selector: 'app-empleados-pagina',
imports: [CurrencyPipe],
providers: [EmpleadosStore],
template: `
<header>
<input
type="search"
placeholder="Buscar empleado..."
aria-label="Buscar empleado"
[value]="store.texto()"
(input)="store.filtrar($any($event.target).value)"
/>
@if (sesion.conectado()) {
<span class="sesion">{{ sesion.email() }}</span>
} @else {
<button
type="button"
class="entrar"
(click)="sesion.entrar({ email: 'ana.garcia@empresa.es', password: 'secreto' })"
>
Entrar como ADMIN
</button>
}
</header>
@if (store.error(); as error) {
<p class="error" role="alert">{{ error }}</p>
}
<table [class.atenuada]="store.cargando()">
<thead>
<tr><th>Nombre</th><th>Oficio</th><th>Salario</th><th></th></tr>
</thead>
<tbody>
@for (empleado of store.entities(); track empleado.empId) {
<tr>
<td>{{ empleado.nombre }} {{ empleado.apellidos }}</td>
<td>{{ empleado.oficio }}</td>
<td>{{ empleado.salario | currency: 'EUR' }}</td>
<td class="acciones">
<button type="button" (click)="store.subirSalario(empleado.empId, 5)">+5 %</button>
<button
type="button"
class="borrar"
[attr.data-id]="empleado.empId"
(click)="store.eliminar(empleado.empId)"
>
Borrar
</button>
</td>
</tr>
} @empty {
<tr><td colspan="4">Sin resultados</td></tr>
}
</tbody>
</table>
<p class="masa">Masa salarial de la lista: {{ store.masaSalarial() | currency: 'EUR' }}</p>
`,
})
export class EmpleadosPagina {
protected readonly store = inject(EmpleadosStore);
protected readonly sesion = inject(SesionStore);
}
La captura muestra lo que pasa al entrar como Ana y pulsar Borrar en su propia fila. Ana tiene subordinados, así que la API responde 409. El empleado desaparece un instante, vuelve, y se muestra el mensaje de la API.

Figura 6. Borrado optimista revertido. Ana estaba la primera y ha vuelto la última: setEntity la añade al final de ids.
Fíjate en el detalle de la figura: Ana ha cambiado de posición. setEntity restaura los datos, pero la entidad se añade al final de ids. Un rollback correcto debe restaurar también el orden. Lo resuelves en el ejercicio 8, y es un buen ejemplo de por qué la opción optimista exige más trabajo que la pesimista: si no te compensa, espera a la respuesta y luego quita la fila.
⚠️ Importante:
removeEntityyremoveEntitiesno recibenselectId, porque ya trabajan con identificadores:removeEntities(predicado, configEmpleado)no compila. Solo aceptan{ collection }, que sí hay que pasar en las colecciones con nombre.
Colecciones con nombre
Un store puede tener varias colecciones. Basta con añadir collection a la configuración, y las señales cambian de nombre: departamentoEntities, departamentoIds, departamentoEntityMap.
// src/app/panel/panel.store.ts (fragmento)
const configDepartamento = entityConfig({
entity: type<Departamento>(),
collection: 'departamento',
selectId: (d) => d.deptId,
});
const configProyecto = entityConfig({
entity: type<Proyecto>(),
collection: 'proyecto',
selectId: (p) => p.proyectoId,
});
8. withProps y withLinkedState 🔄 v22.0
🔄 Cambia en Angular 22.0.
withPropsywithLinkedStateexisten igual en NgRx 21, pero este ejemplo usahttpResource, que en la LTS (Angular 21) es experimental. Si usas la LTS: el mismo código compila con NgRx 21.1.1; revisahttpResourceen las notas de cada actualización.
No todo lo que guarda un store es estado. Una referencia a un servicio, un Observable o un recurso son miembros útiles que no quieres en patchState ni en getState. Para eso está withProps, que añade propiedades normales.
withLinkedState es el linkedSignal del módulo 05 llevado al store: un estado que se recalcula cuando cambia su origen, pero que puedes escribir con patchState.
Este es un panel con departamentos, su plantilla y sus proyectos. Combina todo lo anterior: dos colecciones, un httpResource y un estado enlazado.
// src/app/panel/panel.store.ts
export const PanelStore = signalStore(
withEntities(configDepartamento),
withEntities(configProyecto),
// Estado enlazado: vale el primer departamento hasta que el usuario elija otro
withLinkedState(({ departamentoEntities }) => ({
seleccionId: () => departamentoEntities()[0]?.deptId ?? null,
})),
withComputed(({ proyectoEntities, seleccionId }) => ({
proyectosDelDepartamento: computed(() =>
proyectoEntities().filter((p) => p.deptId === seleccionId()),
),
})),
// Propiedades: no son estado. El prefijo _ las hace privadas
withProps(({ seleccionId }) => ({
_plantilla: httpResource<Pagina<Empleado>>(() => {
const id = seleccionId();
return id === null ? undefined : `${API}/empleados?deptId=${id}&tamano=100`;
}),
})),
withComputed(({ _plantilla }) => ({
plantilla: computed(() => (_plantilla.hasValue() ? _plantilla.value().contenido : [])),
cargandoPlantilla: _plantilla.isLoading,
})),
withMethods((store, http = inject(HttpClient)) => ({
seleccionar(seleccionId: number) {
patchState(store, { seleccionId });
},
cargar: rxMethod<void>(
pipe(
switchMap(() =>
forkJoin({
departamentos: http.get<Pagina<Departamento>>(`${API}/departamentos`),
proyectos: http.get<Pagina<Proyecto>>(`${API}/proyectos`),
}).pipe(
tapResponse({
next: ({ departamentos, proyectos }) =>
patchState(
store,
setAllEntities(departamentos.contenido, configDepartamento),
setAllEntities(proyectos.contenido, configProyecto),
),
error: console.error,
}),
),
),
),
),
})),
withHooks({ onInit: (store) => store.cargar() }),
);
Sigue el recorrido de un clic. seleccionar(30) escribe seleccionId con patchState. Como el recurso lee esa señal, lanza la petición de la plantilla, y el computed de los proyectos se recalcula sin pedir nada.

Figura 7. Un cambio de seleccionId actualiza la plantilla, que viene del servidor, y los proyectos, que ya estaban en memoria.
📘 Concepto: Los miembros que empiezan por
_son privados del store: no aparecen en el tipo que ve el componente, de modo questore._plantillano compila fuera de él. Es una protección de TypeScript, no real. En ejecución la propiedad sigue en el objeto: lo hemos comprobado conObject.keys.
Un store.cargar() sin argumentos funciona porque el tipo es rxMethod<void>.
@ngrx/signals/resource 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21): el subpaquete llega con NgRx 22, y es experimental. Si usas la LTS: conserva el valor durante la carga con
resourceFromSnapshots, como en la sección 10 del módulo 13.
NgRx 22 incluye extendResource, que añade a un recurso de Angular comportamientos que no trae de serie. withPreviousValueOnLoading mantiene el valor anterior mientras carga, para que la lista no parpadee:
import { extendResource, withPreviousValueOnLoading } from '@ngrx/signals/resource';
const departamento = extendResource(
httpResource<Departamento>(() => `${API}/departamentos/${id()}`),
withPreviousValueOnLoading(),
);
Al cambiar id de 10 a 20 obtuvimos el estado loading con el valor del departamento 10, y después resolved con el 20. Sin la extensión, value() no conserva el dato anterior durante la recarga.
9. Reutilizar con signalStoreFeature
withPeticion es una función de capas (custom feature): una función que devuelve un bloque de with... listo para insertar en cualquier store. Es como heredar, pero sin clases: un store puede usar tantas como quiera, y cada una aporta sus propios miembros.
Si la función necesita algo del store que la usa, se declara como primer argumento con type. Así el compilador lo exige:
export function withSeleccion<Entidad>() {
return signalStoreFeature(
{ state: type<EntityState<Entidad>>() }, // exige withEntities antes
withState({ seleccionId: null as EntityId | null }),
withComputed(({ entityMap, seleccionId }) => ({
seleccionada: computed(() => {
const id = seleccionId();
return id === null ? undefined : entityMap()[id];
}),
})),
withMethods((store) => ({
seleccionar(seleccionId: EntityId) {
patchState(store, { seleccionId });
},
deseleccionar() {
patchState(store, { seleccionId: null });
},
})),
);
}
export const TareasConSeleccionStore = signalStore(
withEntities<Tarea>(),
withSeleccion<Tarea>(),
);
Si pones withSeleccion antes de withEntities, no compila. El primer argumento admite state, props y methods.
⚠️ Importante: Si dos capas definen un miembro con el mismo nombre, gana la última y NgRx avisa solo en consola, en desarrollo. Esta es la salida real al crear un
withMethodscon un métodototalsobre un estadototal:@ngrx/signals: SignalStore members cannot be overridden. Trying to override: total. Por eso conviene dar nombres concretos en las funciones reutilizables.
withFeature resuelve otro caso: pasar a una función miembros concretos del store, en lugar de exigir nombres fijos.
withFeature(({ entities }) => withContador(() => entities().length))
Reutilizar tipos entre funciones 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21): el tipo
SignalStoreFeatureTypellega con NgRx 22. Si usas la LTS: escribe a mano lo que exiges, por ejemplo{ state: type<{ foo: string }>() }.
SignalStoreFeatureType<typeof withFeatureA> extrae el resultado de una función de capas para exigirlo en otra sin repetirlo: signalStoreFeature(type<FeatureA>(), ...).
10. El complemento Events
SignalStore permite una forma de trabajar más parecida a Redux, con el subpaquete @ngrx/signals/events. Sigue siendo SignalStore, sin @ngrx/store. Lo verás con más sentido después de leer la sección siguiente, pero aquí lo tienes completo:
// src/app/estadisticas/estadisticas.store.ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, type, withState } from '@ngrx/signals';
import {
eventGroup,
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { exhaustMap } from 'rxjs';
import { API } from '../modelos';
export interface Estadisticas {
empleados: number;
clientes: number;
pedidos: number;
}
export const panelEventos = eventGroup({
source: 'Panel',
events: { abierto: type<void>() },
});
export const apiEventos = eventGroup({
source: 'API Estadisticas',
events: {
cargaCorrecta: type<Estadisticas>(),
cargaFallida: type<string>(),
},
});
export const EstadisticasStore = signalStore(
withState({
datos: null as Estadisticas | null,
cargando: false,
error: null as string | null,
}),
withReducer(
on(panelEventos.abierto, () => ({ cargando: true, error: null })),
on(apiEventos.cargaCorrecta, ({ payload }) => ({ datos: payload, cargando: false })),
on(apiEventos.cargaFallida, ({ payload }) => ({ error: payload, cargando: false })),
),
withEventHandlers((_store, eventos = inject(Events), http = inject(HttpClient)) => ({
cargar$: eventos.on(panelEventos.abierto).pipe(
exhaustMap(() =>
http.get<Estadisticas>(`${API}/estadisticas`).pipe(
mapResponse({
next: (datos) => apiEventos.cargaCorrecta(datos),
error: (e: Error) => apiEventos.cargaFallida(e.message),
}),
),
),
),
})),
);
El componente no llama a métodos, sino que anuncia sucesos:
export class PanelEstadisticas {
protected readonly store = inject(EstadisticasStore);
private readonly despachar = injectDispatch(panelEventos);
constructor() {
this.despachar.abierto();
}
}
injectDispatch da un objeto con un método por evento. Los reductores cambian el estado de forma síncrona, y withEventHandlers hace lo asíncrono: los eventos que emite su Observable se despachan solos. Comprobado contra la API: tras abierto(), cargando() vale true, y al llegar la respuesta datos() contiene 24 empleados y 14 clientes.
Métodos (withMethods) | Eventos (withReducer) | |
|---|---|---|
| Acoplamiento | El componente sabe qué método llamar | El componente solo anuncia |
| Reacción de varios stores | Hay que encadenar llamadas | Todos escuchan el mismo evento |
| Código | Menos | Más |
| Recomendado para | La mayoría de stores | Flujos que atraviesan varias funciones |
💡 Consejo: En NgRx 21
withEffectspasó a llamarsewithEventHandlers, y NgRx incluye una migración para ello. Si encuentras tutoriales conwithEffects, se refieren a lo mismo. Este módulo compila igual con NgRx 21.1.1.
11. Redux en cinco minutos
Antes de que existieran las señales, en Angular el estado global grande se resolvía con NgRx Store, una implementación del patrón Redux. Sigue en miles de aplicaciones, de modo que vas a encontrártelo, y sus ideas se aprovechan en muchos otros sitios.
Redux parte de una observación: los fallos de estado se depuran mal porque no sabes qué pasó para llegar ahí. Su respuesta es muy rígida a propósito:
- El estado está en un solo objeto inmutable, y solo cambia por medio de acciones.
- Una acción es un objeto que describe algo que ha pasado:
[Pedidos API] Carga Correcta. No es una orden, sino un hecho. - Un reducer es una función pura: recibe el estado y la acción, y devuelve el estado nuevo. No hace peticiones ni lee la hora.
- Los selectores extraen y derivan datos, y memorizan el resultado.
- Los efectos hacen todo lo impuro: escuchan acciones, llaman a la API y emiten acciones nuevas.
Figura 8. En azul, el ciclo síncrono. En naranja, la vía asíncrona de los efectos.
La ganancia es que, con la misma secuencia de acciones, siempre se llega al mismo estado. Con esa lista puedes reproducir un fallo, volver atrás en el tiempo con las herramientas de desarrollo, o entender qué ha hecho la aplicación sin poner un solo console.log. El precio es que escribes bastante más.
12. NgRx Store clásico
Construimos una pantalla de pedidos con todas sus piezas. Hay que instalar @ngrx/store, @ngrx/effects, @ngrx/entity y @ngrx/store-devtools, en la misma versión que tus demás paquetes de NgRx.
Acciones
createActionGroup define varias acciones de un mismo origen. Separa siempre lo que hace el usuario de lo que responde la API.
// src/app/pedidos/pedidos.acciones.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { EstadoPedido, Pedido } from '../modelos';
// Lo que hace el usuario en la página
export const PedidosPagina = createActionGroup({
source: 'Pedidos Pagina',
events: {
Abierta: emptyProps(),
'Filtro Cambiado': props<{ estado: EstadoPedido | null }>(),
'Estado Solicitado': props<{ pedidoId: number; estado: EstadoPedido }>(),
},
});
// Lo que responde la API
export const PedidosApi = createActionGroup({
source: 'Pedidos API',
events: {
'Carga Correcta': props<{ pedidos: Pedido[] }>(),
'Cambio Correcto': props<{ pedido: Pedido }>(),
'Peticion Fallida': props<{ error: string }>(),
},
});
Filtro Cambiado se convierte en la función PedidosPagina.filtroCambiado(...), y PedidosPagina.filtroCambiado({ estado: null }).type da el texto [Pedidos Pagina] Filtro Cambiado.
Reducer y selectores con createFeature
createFeature reúne nombre, reducer y selectores. Genera un selector por cada propiedad del estado (selectFiltro, selectError…), y extraSelectors añade los tuyos. @ngrx/entity aporta un adaptador equivalente a withEntities.
// src/app/pedidos/pedidos.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
import { EstadoPedido, Pedido } from '../modelos';
import { PedidosApi, PedidosPagina } from './pedidos.acciones';
export interface EstadoPedidos extends EntityState<Pedido> {
filtro: EstadoPedido | null;
cargando: boolean;
error: string | null;
}
const adaptador = createEntityAdapter<Pedido>({
selectId: (pedido) => pedido.pedidoId,
});
const estadoInicial: EstadoPedidos = adaptador.getInitialState({
filtro: null,
cargando: false,
error: null,
});
export const pedidosFeature = createFeature({
name: 'pedidos',
reducer: createReducer(
estadoInicial,
on(PedidosPagina.abierta, (estado) => ({ ...estado, cargando: true, error: null })),
on(PedidosPagina.filtroCambiado, (estado, { estado: filtro }) => ({ ...estado, filtro })),
on(PedidosApi.cargaCorrecta, (estado, { pedidos }) =>
adaptador.setAll(pedidos, { ...estado, cargando: false }),
),
on(PedidosApi.cambioCorrecto, (estado, { pedido }) =>
adaptador.upsertOne(pedido, estado),
),
on(PedidosApi.peticionFallida, (estado, { error }) => ({
...estado,
cargando: false,
error,
})),
),
extraSelectors: ({ selectPedidosState, selectFiltro }) => {
const { selectAll } = adaptador.getSelectors(selectPedidosState);
const selectVisibles = createSelector(selectAll, selectFiltro, (pedidos, filtro) =>
filtro ? pedidos.filter((p) => p.estado === filtro) : pedidos,
);
const selectImporte = createSelector(selectVisibles, (pedidos) =>
pedidos.reduce((suma, p) => suma + p.total, 0),
);
return { selectVisibles, selectImporte };
},
});
createSelector memoriza: si ni los pedidos ni el filtro han cambiado, devuelve el array anterior sin recalcular. Y un selector puede depender de otro, igual que un computed.
💡 Consejo: NgRx 22 ha anunciado que los selectores con props se eliminarán en NgRx 23. Si los encuentras en código antiguo, cámbialos por una función que crea el selector:
const porId = (id: number) => createSelector(...).
Efectos
Un efecto funcional es una función que recibe un flujo de acciones y devuelve otro flujo de acciones.
// src/app/pedidos/pedidos.efectos.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Service, signal } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { catchError, concatMap, exhaustMap, map, of, tap } from 'rxjs';
import { API, Pagina, Pedido } from '../modelos';
import { PedidosApi, PedidosPagina } from './pedidos.acciones';
const fallo = (e: HttpErrorResponse) =>
of(PedidosApi.peticionFallida({ error: e.error?.mensaje ?? e.message }));
export const cargarPedidos = createEffect(
(acciones$ = inject(Actions), http = inject(HttpClient)) =>
acciones$.pipe(
ofType(PedidosPagina.abierta),
exhaustMap(() =>
http.get<Pagina<Pedido>>(`${API}/pedidos`, { params: { tamano: 100 } }).pipe(
map((pagina) => PedidosApi.cargaCorrecta({ pedidos: pagina.contenido })),
catchError(fallo),
),
),
),
{ functional: true },
);
export const cambiarEstado = createEffect(
(acciones$ = inject(Actions), http = inject(HttpClient)) =>
acciones$.pipe(
ofType(PedidosPagina.estadoSolicitado),
concatMap(({ pedidoId, estado }) =>
http.patch<Pedido>(`${API}/pedidos/${pedidoId}`, { estado }).pipe(
map((pedido) => PedidosApi.cambioCorrecto({ pedido })),
catchError(fallo),
),
),
),
{ functional: true },
);
@Service()
export class RegistroAcciones {
readonly tipos = signal<string[]>([]);
}
// Efecto sin salida: mira pasar todas las acciones y no lanza ninguna
export const registrarAcciones = createEffect(
(acciones$ = inject<Actions<Action>>(Actions), registro = inject(RegistroAcciones)) =>
acciones$.pipe(tap((accion) => registro.tipos.update((t) => [...t, accion.type]))),
{ functional: true, dispatch: false },
);
RegistroAcciones solo sirve para mostrar en pantalla las acciones; en la LTS, cambia su @Service() por @Injectable({ providedIn: 'root' }). Las reglas de la sección 6 valen igual aquí: catchError va dentro del operador de aplanado para que el efecto siga vivo, y cada operador tiene su motivo. Cargar usa exhaustMap para ignorar una apertura repetida. Cambiar el estado usa concatMap para respetar el orden. La opción dispatch: false indica que el efecto no emite acciones, y sin ella un efecto que devuelve lo que recibe entraría en un bucle infinito.
Registrar el Store
// src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([conToken])),
provideStore(),
provideEffects(),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
};
// src/app/app.routes.ts (fragmento)
{
path: 'pedidos',
component: ListaPedidos,
providers: [
provideState(pedidosFeature),
provideEffects({
cargarPedidos: efectos.cargarPedidos,
cambiarEstado: efectos.cambiarEstado,
registrarAcciones: efectos.registrarAcciones,
}),
],
},
provideStore y provideEffects se ponen vacíos en la raíz. El estado y los efectos de cada función se registran en su ruta, y se cargan cuando se entra en ella. import * as efectos from './pedidos/pedidos.efectos' recoge los tres.
El componente
El Store clásico nació con Observables, pero selectSignal entrega cualquier selector como señal.
// src/app/pedidos/pedidos-pagina.ts (sin estilos)
import { CurrencyPipe } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { EstadoPedido } from '../modelos';
import { SesionStore } from '../sesion/sesion.store';
import { PedidosPagina as Acciones } from './pedidos.acciones';
import { RegistroAcciones } from './pedidos.efectos';
import { pedidosFeature } from './pedidos.reducer';
@Component({
selector: 'app-pedidos-pagina',
imports: [CurrencyPipe],
template: `
<div class="filtros">
<button type="button" [class.activo]="filtro() === null" (click)="filtrar(null)">Todos</button>
@for (estado of estados; track estado) {
<button type="button" [class.activo]="filtro() === estado" (click)="filtrar(estado)">
{{ estado }}
</button>
}
</div>
@if (error(); as error) {
<p class="error" role="alert">{{ error }}</p>
}
<div class="columnas">
<table>
<tbody>
@for (pedido of pedidos(); track pedido.pedidoId) {
<tr>
<td>{{ pedido.pedidoId }}</td>
<td>{{ pedido.cliente }}</td>
<td>{{ pedido.estado }}</td>
<td class="num">{{ pedido.total | currency: 'EUR' }}</td>
<td>
@if (pedido.estado === 'PENDIENTE') {
<button type="button" class="enviar" (click)="enviar(pedido.pedidoId)">Enviar</button>
}
</td>
</tr>
}
</tbody>
</table>
<aside>
<h2>Acciones</h2>
<ol>
@for (tipo of registro.tipos(); track $index) {
<li>{{ tipo }}</li>
}
</ol>
</aside>
</div>
<p class="importe">Importe de la selección: {{ importe() | currency: 'EUR' }}</p>
`,
})
export class ListaPedidos implements OnInit {
private readonly store = inject(Store);
protected readonly registro = inject(RegistroAcciones);
private readonly sesion = inject(SesionStore);
protected readonly estados: EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];
protected readonly pedidos = this.store.selectSignal(pedidosFeature.selectVisibles);
protected readonly importe = this.store.selectSignal(pedidosFeature.selectImporte);
protected readonly filtro = this.store.selectSignal(pedidosFeature.selectFiltro);
protected readonly error = this.store.selectSignal(pedidosFeature.selectError);
ngOnInit() {
this.store.dispatch(Acciones.abierta());
// Solo para la demostración: en una aplicación real habría pantalla de acceso
this.sesion.entrar({ email: 'ana.garcia@empresa.es', password: 'secreto' });
}
protected filtrar(estado: EstadoPedido | null) {
this.store.dispatch(Acciones.filtroCambiado({ estado }));
}
protected enviar(pedidoId: number) {
this.store.dispatch(Acciones.estadoSolicitado({ pedidoId, estado: 'ENVIADO' }));
}
}
Fíjate en que el componente no sabe si hay un efecto, ni qué URL se usa. Solo anuncia.

Figura 9. Registro real de acciones tras abrir la página, filtrar por PENDIENTE y enviar un pedido, que por eso sale del filtro.
Esta es la lista exacta que se ve en la figura, y es la mayor virtud del patrón: se lee como un relato de lo ocurrido.
[Pedidos Pagina] Abierta
[Pedidos API] Carga Correcta
[Pedidos Pagina] Filtro Cambiado
[Pedidos Pagina] Estado Solicitado
[Pedidos API] Cambio Correcto
Redux DevTools
provideStoreDevtools conecta la aplicación con la extensión Redux DevTools del navegador. Con ella ves cada acción, el estado antes y después, y puedes volver a un punto anterior (time travel). maxAge limita el historial, y logOnly evita las funciones de escritura en producción.
⚠️ Importante: El Store clásico sí protege el estado. Por defecto, en desarrollo, congela estado y acciones. Modificar un objeto leído de un selector lanzó este error en nuestra prueba:
Cannot assign to read only property 'nombre' of object '#<Object>'. Recuerda que SignalStore no lo hace.
13. Probar el estado
Las pruebas se ven a fondo en el módulo 26, pero aquí se nota la ventaja de sacar la lógica de los componentes. Un store se prueba sin plantilla ni DOM.
// src/app/carrito/carrito.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
import { CarritoStore } from './carrito.store';
describe('CarritoStore', () => {
beforeEach(() => localStorage.clear());
it('aplica el cupón al total', () => {
const carrito = TestBed.inject(CarritoStore);
carrito.anadir({ productoId: 1, nombre: 'P1', precio: 100, stock: 1, categoria: 'x' });
carrito.aplicarCupon('EMPRESA10');
expect(carrito.total()).toBe(90);
});
it('prepara un estado concreto sin pasar por los métodos', () => {
const carrito = TestBed.inject(CarritoStore);
patchState(unprotected(carrito), { cupon: null });
expect(carrito.total()).toBe(0);
});
});
unprotected quita la protección solo en la prueba, para preparar la situación que te interesa. Y TestBed.tick() fuerza la ejecución de los efectos pendientes, algo necesario para comprobar lo que hace un effect.
Con el Store clásico es todavía más simple: un reducer es una función pura, y un selector se llama con el estado.
it('filtra los pedidos', () => {
const inicial = pedidosFeature.reducer(undefined, { type: '@@init' });
const cargado = pedidosFeature.reducer(inicial, PedidosApi.cargaCorrecta({ pedidos }));
const filtrado = pedidosFeature.reducer(
cargado,
PedidosPagina.filtroCambiado({ estado: 'PENDIENTE' }),
);
expect(pedidosFeature.selectVisibles({ pedidos: filtrado }).map((p) => p.pedidoId)).toEqual([2]);
expect(pedidosFeature.selectImporte({ pedidos: filtrado })).toBe(20);
});
Si tu prueba depende de datos remotos, usa provideHttpClientTesting, como se explica en el módulo 26.
14. Depuración y errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
NG0201: No provider found for 'SignalStore' | El store no lleva providedIn ni está en providers | Añade uno de los dos |
El mensaje cita SignalStore, no el nombre de tu store | La clase que crea signalStore siempre se llama así | Busca qué inject hay en el componente que falla |
rxMethod funciona una vez y luego deja de responder | Un error ha cerrado la tubería | Pon tapResponse dentro del switchMap |
| La vista no cambia tras modificar un objeto | Has mutado en lugar de crear uno nuevo | Copia con ... o map; SignalStore no da error |
Property 'x' does not exist on type '{}' | La capa que define x va después | Reordena las capas |
Argument of type 'Empleado[]' is not assignable to '{ id: EntityId; }[]' | Falta la configuración de entidades | Pasa entityConfig al actualizador |
Falla patchState en un componente | El estado está protegido | Crea un método en el store |
Aviso SignalStore members cannot be overridden | Dos capas usan el mismo nombre | Renombra uno |
| Un efecto entra en bucle | Devuelve la misma acción que escucha | Emite otra acción, o usa dispatch: false |
| Un efecto deja de responder tras un error | catchError está fuera del switchMap | Muévelo dentro |
| Los datos del usuario anterior siguen ahí | El estado global no se limpia | Vacíalo al cerrar sesión |
Tres herramientas para ver qué ocurre en un SignalStore, que no tiene DevTools propias en NgRx:
watchState(store, console.log)escribe cada cambio. Es elconsole.logdel estado.getState(store)da la foto en un punto concreto.- Angular DevTools, del módulo 01, permite inspeccionar los componentes en ejecución.
15. Cuándo usar cada uno
Figura 10. Avanza por las preguntas y detente en la primera respuesta que encaje.
| Servicio con señales | SignalStore | Store clásico | |
|---|---|---|---|
| Dependencias | Ninguna | @ngrx/signals | @ngrx/store y más |
| Curva de aprendizaje | Baja | Media | Alta |
| Código por función | Poco | Poco | Mucho |
| Estado | Clase con señales | Capas | Árbol global |
| Alcance | Raíz o componente | Raíz o componente | Global, por funciones |
| Escritura | Tus métodos | patchState en métodos | Solo con acciones |
| Colecciones | A mano | withEntities | @ngrx/entity |
| Reutilización | Inyectar otro servicio | signalStoreFeature | Metarreductores, selectores |
| Asincronía | httpResource, HttpClient | rxMethod, withProps | Efectos |
| Historial de cambios | No | No de serie | Sí, Redux DevTools |
| Inmutabilidad vigilada | No | No | Sí, en desarrollo |
Una forma práctica de pensarlo, de menos a más:
- Empieza con señales en el componente.
- Si lo comparten componentes lejanos, usa un servicio con señales.
- Si ese servicio empieza a repetir patrones de carga y colecciones, pásalo a SignalStore. Es un cambio barato, porque los componentes apenas se enteran.
- Elige el Store clásico cuando el proyecto ya lo usa, o cuando te compense el coste: registro de acciones, reproducción de fallos, muchos equipos sobre el mismo estado.
En un proyecto nuevo, lo habitual hoy es combinar las tres primeras: señales locales, httpResource para leer, y un SignalStore para las funciones con reglas propias. El proyecto final lo hace así. Y no hace falta elegir solo uno: conviven en la misma aplicación, como aquí, donde ListaPedidos usa el Store clásico y el SesionStore a la vez.
💡 Consejo: No migres un Store clásico que funciona solo porque SignalStore sea más moderno. Reescribir no aporta nada al usuario. Usa SignalStore en lo nuevo y deja convivir ambos.
16. Ejercicios
Los ejercicios usan la API EMPRESA. Todas las soluciones se han compilado y probado. Si usas la LTS, cambia cada @Service() por @Injectable({ providedIn: 'root' }) e instala NgRx 21.
Ejercicio 1: Preferencias persistentes
Crea un servicio PreferenciasEstado con un tema (claro u oscuro) y una densidad (normal o compacta). Debe exponer señales de solo lectura, una señal clases del tipo tema-oscuro densidad-compacta, un método alternarTema, otro cambiarDensidad, y guardarse en localStorage.
Ver solución
export type Tema = 'claro' | 'oscuro';
export type Densidad = 'normal' | 'compacta';
interface Preferencias {
tema: Tema;
densidad: Densidad;
}
const CLAVE_PREFERENCIAS = 'empresa.preferencias';
function leerPreferencias(): Preferencias {
const texto = localStorage.getItem(CLAVE_PREFERENCIAS);
return texto ? (JSON.parse(texto) as Preferencias) : { tema: 'claro', densidad: 'normal' };
}
@Service()
export class PreferenciasEstado {
private readonly _preferencias = signal<Preferencias>(leerPreferencias());
readonly tema = computed(() => this._preferencias().tema);
readonly densidad = computed(() => this._preferencias().densidad);
readonly clases = computed(() => `tema-${this.tema()} densidad-${this.densidad()}`);
constructor() {
effect(() =>
localStorage.setItem(CLAVE_PREFERENCIAS, JSON.stringify(this._preferencias())),
);
}
alternarTema() {
this._preferencias.update((p) => ({
...p,
tema: p.tema === 'claro' ? 'oscuro' : 'claro',
}));
}
cambiarDensidad(densidad: Densidad) {
this._preferencias.update((p) => ({ ...p, densidad }));
}
}
Tras alternarTema() y cambiarDensidad('compacta'), clases() da tema-oscuro densidad-compacta y localStorage guarda {"tema":"oscuro","densidad":"compacta"}.
Un computed sobre una sola señal privada da solo lectura sin necesidad de asReadonly, y mantiene unidos los campos que se guardan juntos.
Ejercicio 2: Cola de avisos
Crea un servicio AvisosEstado. avisar(texto) añade un aviso y devuelve su id. Solo se muestran 3 a la vez: visibles da los tres primeros y enEspera da cuántos quedan. descartar(id) quita uno, y el siguiente de la cola debe aparecer solo.
Ver solución
export interface Aviso {
id: number;
texto: string;
}
const MAXIMO_VISIBLES = 3;
@Service()
export class AvisosEstado {
private siguienteId = 1;
private readonly cola = signal<Aviso[]>([]);
readonly visibles = computed(() => this.cola().slice(0, MAXIMO_VISIBLES));
readonly enEspera = computed(() => Math.max(0, this.cola().length - MAXIMO_VISIBLES));
avisar(texto: string): number {
const id = this.siguienteId++;
this.cola.update((cola) => [...cola, { id, texto }]);
return id;
}
descartar(id: number) {
this.cola.update((cola) => cola.filter((aviso) => aviso.id !== id));
}
}
Con cinco avisos (a a e), visibles muestra abc y enEspera vale 2. Al descartar a, aparecen bcd y queda 1. No hay que mover nada a mano: visibles y enEspera son derivados de la cola. Esa es la ventaja de guardar lo mínimo.
Ejercicio 3: Paginación con SignalStore
Crea un ClientesStore para /api/clientes con pagina, tamano, total y clientes. Calcula paginas, hayAnterior y haySiguiente, y escribe anterior() y siguiente(), que no deben salirse de los límites. La carga debe lanzarse sola cuando cambie la página.
Ver solución
export interface Cliente {
clienteId: number;
nombre: string;
}
export const ClientesStore = signalStore(
withState({
clientes: [] as Cliente[],
pagina: 1,
tamano: 5,
total: 0,
}),
withComputed(({ pagina, tamano, total }) => {
const paginas = computed(() => Math.max(1, Math.ceil(total() / tamano())));
return {
paginas,
hayAnterior: computed(() => pagina() > 1),
haySiguiente: computed(() => pagina() < paginas()),
consulta: computed(() => ({ pagina: pagina(), tamano: tamano() })),
};
}),
withMethods((store, http = inject(HttpClient)) => ({
anterior() {
if (store.hayAnterior()) {
patchState(store, ({ pagina }) => ({ pagina: pagina - 1 }));
}
},
siguiente() {
if (store.haySiguiente()) {
patchState(store, ({ pagina }) => ({ pagina: pagina + 1 }));
}
},
cargar: rxMethod<{ pagina: number; tamano: number }>(
pipe(
switchMap((params) =>
http.get<Pagina<Cliente>>(`${API}/clientes`, { params }).pipe(
tapResponse({
next: ({ contenido, total }) => patchState(store, { clientes: contenido, total }),
error: console.error,
}),
),
),
),
),
})),
withHooks({
onInit: (store) => store.cargar(store.consulta),
}),
);
La pieza clave es consulta, un computed que reúne los parámetros. Al pasarlo a rxMethod, cualquier cambio de pagina o tamano recarga la lista.
Comprobado contra la API: al principio hay 3 páginas y el primer cliente es el 1001; anterior() no hace nada, y siguiente() lleva a la página 2, cuyo primer cliente es el 1006.
Ejercicio 4: Proyectos con entidades
Crea un ProyectosStore global con withEntities, usando proyectoId como identificador. Debe cargar /api/proyectos, con señales cargando y error, y calcular presupuestoTotal.
Ver solución
const configProyecto = entityConfig({
entity: type<Proyecto>(),
selectId: (proyecto) => proyecto.proyectoId,
});
export const ProyectosStore = signalStore(
{ providedIn: 'root' },
withEntities(configProyecto),
withState({ cargando: false, error: null as string | null }),
withComputed(({ entities }) => ({
presupuestoTotal: computed(() => entities().reduce((s, p) => s + p.presupuesto, 0)),
})),
withMethods((store, http = inject(HttpClient)) => ({
cargar: rxMethod<void>(
pipe(
tap(() => patchState(store, { cargando: true, error: null })),
switchMap(() =>
http.get<Pagina<Proyecto>>(`${API}/proyectos`).pipe(
tapResponse({
next: ({ contenido }) =>
patchState(store, setAllEntities(contenido, configProyecto), { cargando: false }),
error: (e: Error) => patchState(store, { error: e.message, cargando: false }),
}),
),
),
),
),
})),
);
Tras cargar(), hay 5 proyectos y presupuestoTotal() da 285000. En este patchState se mezclan un actualizador de entidades y un objeto: se aplican en orden, dentro del mismo cambio.
Ejercicio 5: Lista de tareas
Crea un TareasStore local, con id propio. Debe tener anadir(titulo), alternar(id), completarTodas(), borrar(id) y limpiarHechas(), y las señales pendientes y porcentaje.
Ver solución
export interface Tarea {
id: number;
titulo: string;
hecha: boolean;
}
export const TareasStore = signalStore(
withEntities<Tarea>(),
withState({ siguienteId: 1 }),
withComputed(({ entities }) => ({
pendientes: computed(() => entities().filter((t) => !t.hecha)),
porcentaje: computed(() => {
const total = entities().length;
return total === 0 ? 0 : Math.round((entities().filter((t) => t.hecha).length / total) * 100);
}),
})),
withMethods((store) => ({
anadir(titulo: string) {
patchState(
store,
addEntity<Tarea>({ id: store.siguienteId(), titulo, hecha: false }),
({ siguienteId }) => ({ siguienteId: siguienteId + 1 }),
);
},
alternar(id: number) {
patchState(store, updateEntity({ id, changes: ({ hecha }) => ({ hecha: !hecha }) }));
},
completarTodas() {
patchState(store, updateAllEntities({ hecha: true }));
},
borrar(id: number) {
patchState(store, removeEntity(id));
},
limpiarHechas() {
patchState(store, removeEntities(({ hecha }) => hecha));
},
})),
);
Con tres tareas y alternar(2) hay 2 pendientes y un 33 %. limpiarHechas() deja los id 1 y 3. completarTodas() da un 100 %. Como la entidad tiene id, no hace falta entityConfig.
Ejercicio 6: Selección reutilizable
Escribe la función withSeleccion<Entidad>(), que funcione sobre cualquier store con withEntities. Añade seleccionId, seleccionada, seleccionar(id) y deseleccionar().
Ver solución
La solución es el código de la sección 9. Lo importante es el primer argumento, { state: type<EntityState<Entidad>>() }, que hace que entityMap exista dentro de la función y que obliga a poner withEntities antes. Probada con una tarea de id 7: seleccionar(7) deja seleccionada()?.titulo en 'Siete', y deseleccionar() devuelve undefined.
Para rellenar el store en la prueba se usó patchState(unprotected(store), addEntity(...)).
Ejercicio 7: Categorías con Store clásico
Crea un grupo de acciones, un feature y un efecto para cargar /api/categorias. Añade un selector selectRaices con las categorías sin padre. Usa mapResponse.
Ver solución
export interface Categoria {
categoriaId: number;
nombre: string;
categoriaPadreId: number | null;
}
export const CategoriasAcciones = createActionGroup({
source: 'Categorias',
events: {
Pedidas: emptyProps(),
Recibidas: props<{ categorias: Categoria[] }>(),
Fallidas: props<{ error: string }>(),
},
});
export const categoriasFeature = createFeature({
name: 'categorias',
reducer: createReducer(
{ lista: [] as Categoria[], cargando: false, error: null as string | null },
on(CategoriasAcciones.pedidas, (estado) => ({ ...estado, cargando: true, error: null })),
on(CategoriasAcciones.recibidas, (estado, { categorias }) => ({
...estado,
lista: categorias,
cargando: false,
})),
on(CategoriasAcciones.fallidas, (estado, { error }) => ({
...estado,
error,
cargando: false,
})),
),
extraSelectors: ({ selectLista }) => ({
selectRaices: createSelector(selectLista, (lista) =>
lista.filter((c) => c.categoriaPadreId === null),
),
}),
});
export const cargarCategorias = createEffect(
(acciones$ = inject(Actions), http = inject(HttpClient)) =>
acciones$.pipe(
ofType(CategoriasAcciones.pedidas),
exhaustMap(() =>
http.get<Pagina<Categoria>>(`${API}/categorias`, { params: { tamano: 100 } }).pipe(
map(({ contenido }) => contenido),
mapResponse({
next: (categorias) => CategoriasAcciones.recibidas({ categorias }),
error: (e: Error) => CategoriasAcciones.fallidas({ error: e.message }),
}),
),
),
),
{ functional: true },
);
Se registra con provideState(categoriasFeature) y provideEffects({ cargarCategorias }). Tras despachar pedidas(), hay 11 categorías y selectRaices devuelve Informática, Electrónica y Oficina.
mapResponse hace el papel de map más catchError, y exige escribir los dos caminos.
Ejercicio 8 (propuesto): Rollback con orden
En EmpleadosStore, el borrado revertido deja al empleado al final de la lista (figura 6). Modifica eliminar para guardar store.ids() y store.entityMap() antes de borrar, y restaurar los dos. Pista: patchState admite un objeto parcial con esas dos propiedades. Compruébalo intentando borrar a Ana.
Ejercicio 9 (propuesto): Carrito con withEntities
Reescribe CarritoStore para usar withEntities, con productoId como identificador. Compara cuánto se simplifican anadir y quitar. ¿Qué actualizador te conviene para sumar una unidad si el producto ya existe?
Ejercicio 10 (propuesto): El mismo requisito, tres veces
Implementa la carga de /api/departamentos con un filtro de texto de tres maneras: servicio con httpResource, SignalStore con rxMethod y Store clásico con efecto. Cuenta los ficheros y las líneas de cada una, y escribe qué herramienta elegirías y por qué en un proyecto de dos personas, y en otro de cuarenta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Fuente de verdad | signal y computed | Cada dato en un sitio; lo derivado no se guarda |
| Servicio de estado | @Service() 🆕, asReadonly() | Señal privada, lectura pública, métodos con nombre |
| Alcance | providers, autoProvided: false 🆕 | Dónde se proporciona decide cuánto dura |
| Persistencia | effect, watchState | Sincronizar hacia fuera, nunca derivar |
| Versiones | NgRx 22 🔄, NgRx 21.1.1 en LTS | Su mayor debe coincidir con la de Angular |
| Crear un store | signalStore | Devuelve una clase; las capas se aplican en orden |
| Estado | withState, patchState | Protegido; sin congelar, nunca lo mutes |
| Derivados | withComputed | Devuelve señales |
| Operaciones | withMethods | Contexto de inyección |
| Ciclo de vida | withHooks | onInit y onDestroy |
| Asincronía | rxMethod, tapResponse | El error se trata dentro del switchMap |
| Sin RxJS | signalMethod | Basado en effect; agrupa cambios |
| Colecciones | withEntities, entityConfig | ids, entityMap y entities |
| Miembros extra | withProps, prefijo _ | Para recursos y servicios; privado solo en tipos |
| Estado enlazado | withLinkedState | linkedSignal dentro del store |
| Extender recursos | extendResource 🆕 | Experimental, solo NgRx 22 |
| Reutilizar | signalStoreFeature, type | Composición en vez de herencia |
| Eventos | eventGroup, withReducer, withEventHandlers | Redux dentro de SignalStore |
| Store clásico | createActionGroup, createFeature | Acción, reducer, selector |
| Efectos | createEffect, ofType | Lo impuro, fuera del reducer |
| Lectura | selectSignal | Un selector como señal |
| Depuración | provideStoreDevtools, getState | Historial en Store clásico |
| Pruebas | unprotected, funciones puras | Sin DOM ni plantilla |
| Elección | Señal, servicio, SignalStore, Store | Empieza simple; sube cuando duela |