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 con effect
  • Instalar la versión de NgRx que corresponde a tu versión de Angular
  • Crear un NgRx SignalStore con signalStore, withState, withComputed, withMethods y withHooks
  • Cambiar el estado con patchState, y protegerlo con protectedState
  • Tratar peticiones con rxMethod y tapResponse, y sin RxJS con signalMethod
  • Gestionar colecciones con withEntities, sus actualizadores y las colecciones con nombre
  • Combinar SignalStore con httpResource mediante withProps, y usar withLinkedState
  • 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 con node server.mjs y quedará en http://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 de output(), 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:

  1. Una sola fuente de verdad. Cada dato se guarda en un único sitio. Lo demás son lecturas o cálculos.
  2. 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.
  3. 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.

Tres niveles de alcance (local, compartido y global) frente a tres tipos de estado con herramienta propia: servidor, URL y formulario

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 httpResource puede 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:

  • _lineas es privada y lineas es de solo lectura. asReadonly() devuelve una señal sin set ni update. Un componente no puede escribir carrito.lineas.set([]), porque no compila. Todo cambio pasa por un método con nombre.
  • unidades, total y vacio son computed. Si fueran señales, cada método debería recordar actualizarlas. Así es imposible que se contradigan con las líneas.
  • update crea un array nuevo. map, filter y el operador ... devuelven objetos nuevos. Si hicieras lineas.push(...) y devolvieras el mismo array, la señal compararía con Object.is, vería el mismo objeto y no avisaría a nadie. Lo viste en el módulo 05.
  • El effect sincroniza hacia fuera. Un efecto sirve para llevar el estado a algo que no es Angular, como localStorage. 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.

Catálogo de productos a la izquierda y, a la derecha, un carrito con tres unidades, dos líneas y el total

Figura 2. Catalogo y ResumenCarrito no se pasan nada: comparten CarritoEstado. Al recargar la página, el carrito sigue ahí gracias a localStorage.

Dos componentes alrededor del servicio: los métodos escriben en la señal privada, y las señales de lectura avisan a los componentes

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. httpResource pasa 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' }), pero httpResource puede 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 en providers; 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 cargando y error, y el mismo update para 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 con npm 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/signals también lo instala, y escoge la versión compatible con tu proyecto. @ngrx/signals no 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.

Cuatro cajas en fila: withState, withComputed, withMethods y withHooks, y debajo la clase inyectable que resulta

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: patchState hace 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 directamente carrito.lineas()[0].cantidad = 99 no 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 watchState y effect: un effect se ejecuta más tarde y agrupa varios cambios en una sola ejecución. watchState se ejecuta una vez al registrarlo y luego, de forma síncrona, con cada patchState. 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: tapResponse siempre 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.

Tres cajas: ids con el orden, entityMap con cada empleado por su clave, y entities, el array que resulta

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 deduce hecha: false como tipo literal y falla con Type '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.

Página de empleados con un buscador, un mensaje de error de la API y la tabla, donde Ana García ha vuelto en la última fila

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: removeEntity y removeEntities no reciben selectId, 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. withProps y withLinkedState existen igual en NgRx 21, pero este ejemplo usa httpResource, que en la LTS (Angular 21) es experimental. Si usas la LTS: el mismo código compila con NgRx 21.1.1; revisa httpResource en 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.

Panel con los departamentos como botones, INFORMÁTICA activo, su plantilla de seis personas y sus dos proyectos

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 que store._plantilla no compila fuera de él. Es una protección de TypeScript, no real. En ejecución la propiedad sigue en el objeto: lo hemos comprobado con Object.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 withMethods con un método total sobre un estado total: @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 SignalStoreFeatureType llega 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 withEffects pasó a llamarse withEventHandlers, y NgRx incluye una migración para ello. Si encuentras tutoriales con withEffects, 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.

Ciclo: el componente despacha una acción, el reducer crea el estado, el selector lo entrega; el efecto escucha acciones, llama a la API y lanza otra acción

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.

Lista de pedidos filtrada por PENDIENTE y, a la derecha, la lista de cinco acciones que han pasado por el Store

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 el console.log del 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

Diagrama de decisión con cuatro preguntas que llevan a señal, servicio con señales, SignalStore o Store clásico

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:

  1. Empieza con señales en el componente.
  2. Si lo comparten componentes lejanos, usa un servicio con señales.
  3. 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.
  4. 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