Inicio Blog Acerca de
NgRx SignalStore Events: El Poder de los Eventos en tu Estado

NgRx SignalStore Events: El Poder de los Eventos en tu Estado

13 min de lectura angular ngrx signalstore state-management
Tabla de Contenidos

El NgRx SignalStore se ha convertido rápidamente en un favorito para gestionar el estado en aplicaciones Angular debido a su simplicidad y flexibilidad. Sin embargo, a medida que las aplicaciones crecen, a veces nos topamos con una pared donde "métodos llamando a métodos" llevan a un acoplamiento fuerte y código espagueti.

En este post, exploraré el plugin de Eventos de NgRx SignalStore, introducido por primera vez en NgRx v19.2 y ahora oficialmente estable desde NgRx 21. Este plugin trae el poder de la arquitectura orientada a eventos al SignalStore, proporcionando un conjunto de APIs para definir y manejar eventos de una manera reactiva y declarativa.

¿Qué es el Plugin de Eventos?

El propósito oficial del plugin de Eventos es desacoplar qué pasó de cómo cambia el estado. Extiende el SignalStore con una capa de gestión de estado basada en eventos, inspirándose en la arquitectura Flux original.

Para usar el plugin efectivamente, necesitas entender sus cuatro bloques de construcción principales:

  1. Event (Evento): Declaraciones explícitas de un suceso en tu sistema.
  2. Dispatcher (Despachador): Un bus de eventos que reenvía eventos a sus correspondientes manejadores.
  3. Store: Contiene manejadores de eventos que gestionan transiciones de estado y efectos secundarios.
  4. View (Vista): Refleja cambios de estado y despacha nuevos eventos, permitiendo una interacción continua entre la interfaz de usuario y el sistema subyacente.
graph LR
    Event[Evento]:::purple --> Dispatcher[Dispatcher]
    Dispatcher --> Store["Store
(Manejadores de Eventos)"] Store --> View[Vista] View --> Event2[Evento]:::purple Event2 --> Dispatcher classDef purple stroke:#a855f7,stroke-width:2px,color:#a855f7;

En su núcleo, reintroduce conceptos familiares del clásico NgRx Store: Eventos (Actions), Reducers y Event Handlers (Effects).

Insight: En el workshop de SignalStore, hablamos sobre que en Redux técnicamente una "Action" es realmente un "Evento". Este plugin finalmente corrige esa terminología: Los Eventos representan algo que sucedió (como una interacción del usuario), en lugar de un comando para hacer algo.

En lugar de que tu componente llame a un método (withMethods) que actualiza activamente el estado y dispara una llamada a la API, tu componente simplemente despacha un Evento. El store entonces escucha ese evento para actualizar su estado (vía un Reducer) o realizar un efecto secundario (vía un Effect).

Esta inversión de control es poderosa. Significa que tu UI no necesita saber qué sucede cuando un usuario hace clic en un botón, solo que el botón fue presionado.

Instalación

La funcionalidad de eventos es parte del paquete @ngrx/signals. Con NgRx 21, ahora es estable y está listo para uso en producción.

npm install @ngrx/signals
# o, como prefiero yo
pnpm add @ngrx/signals

Requerimientos: Angular 21.x, TypeScript 5.9.x, RxJS ^6.5.x o ^7.5.x

Ejemplo Práctico

Suficiente teoría, veamos cómo funciona esto en la práctica.

He creado una pequeña aplicación para demostrar estos conceptos: un buscador de series de TV. Cuenta con una barra de búsqueda, un componente de resultados y una barra lateral detallada que aparece cuando se selecciona una serie. Aquí un vistazo al resultado final:

Series Search App Demo

Muy bien, manos a la obra. Este es el repo si quieres seguir todo el código. o puedes ver la demo en vivo aquí.

Antes de sumergirnos en el código, definamos el alcance de nuestro ejemplo. Nos centraremos en dos interacciones principales del usuario:

  1. queryChanged: Disparado cuando el usuario busca una serie, ya sea haciendo clic en el botón de búsqueda o presionando Enter.
  2. seriesSelected: Disparado cuando el usuario hace clic en una serie de los resultados para ver más detalles.

Para este alcance de UI, estos son los únicos dos eventos que necesitamos. Sin embargo, también definiremos eventos de API para manejar los estados de éxito y fallo de nuestra obtención de datos: uno para la consulta de búsqueda y otro para recuperar los detalles específicos de una serie seleccionada.

1. Definiendo Eventos

Puedes declarar eventos individualmente usando la función event. Sin embargo, usar eventGroup se considera una mejor práctica para el mantenimiento. A medida que tu funcionalidad crece, también es muy recomendable organizar estos grupos en archivos dedicados.

Definiremos dos grupos de eventos: uno para interacciones de UI (SeriesEvents) y uno para respuestas de API (SeriesApiEvents).

import { type } from "@ngrx/signals";
import { eventGroup } from "@ngrx/signals/events";
import { Serie, SerieDetail } from "../shared/models";

export const SeriesEvents = eventGroup({
  source: "Series",
  events: {
    // Search Input
    queryChanged: type<{ query: string }>(),
    seriesSelected: type<{ theTvDbId: number }>(),
  },
});

export const SeriesApiEvents = eventGroup({
  source: "Series Api",
  events: {
    // Successful retrieve of data
    loadedSuccess: type<Serie[]>(),
    // Failed retrieve of data
    loadedFailure: type<string>(),
    // Successful retrieve of detail data
    detailLoadedSuccess: type<SerieDetail>(),
    // Failed retrieve of detail data
    detailLoadedFailure: type<string>(),
  },
});

💡 Consejo de Naming: Trata los eventos como hechos históricos. Usa tiempo pasado (selected, loaded, changed) y enfócate en la intención del usuario (queryChanged) en lugar de la interacción física (buttonClicked). Esto hace que tu Store sea resiliente a cambios en la UI.

2. Construyendo el Store

Primero, definimos la forma de nuestro estado e inicializamos el store. En este punto, es solo un SignalStore estándar con estado.

export interface SeriesState {
  series: Serie[];
  searchState: "initial" | "loading" | "loaded" | "error";
  selectedId: number | null;
  // ... other state properties
}

const initialSeriesState: SeriesState = {
  series: [],
  searchState: "initial",
  selectedId: null,
};

export const SeriesStore = signalStore(
  withState<SeriesState>(initialSeriesState),
  // ... agregaremos funcionalidades aquí
);

💡 Consejo de Estado: Mantén tu store mínimo. No almacenes datos derivados como filteredSeries o activeSeries. En su lugar, almacena el array crudo series y usa withComputed para derivar vistas específicas. Esto previene bugs de sincronización de estado y aprovecha el poder de la memoización de Signals.

Nota: Hasta este punto, el código es idéntico a un SignalStore estándar. La diferencia comienza cuando empezamos a manejar efectos secundarios con eventos en lugar de métodos.

3. Actualizaciones Reactivas de Estado (Reducers)

withReducer actúa como el lugar centralizado para las transiciones de estado. Aquí, nos suscribimos a varios eventos—ya sean disparados por interacciones del usuario (como iniciar una búsqueda) o sistemas externos (como una respuesta de API)—y definimos estrictamente cómo debe evolucionar el estado en respuesta.

Esto mantiene nuestra lógica de estado pura:

// Dentro de signalStore...
withReducer(
  // Nota el uso de argumentos desestructurados para acceder al `payload` directamente en la función callback.
  // --- Flujo de Búsqueda ---
  // Usuario cambia query -> Set loading state & update query inmediatamente
  on(SeriesEvents.queryChanged, ({ payload: { query } }) => ({
    query,
    searchState: "loading",
  })),
  // API retorna éxito -> Update series & set loaded state
  on(SeriesApiEvents.loadedSuccess, ({ payload: series }) => ({
    series,
    searchState: "loaded",
  })),
  // API falla -> Set error state
  on(SeriesApiEvents.loadedFailure, () => ({
    searchState: "error",
  })),

  // --- Flujo de Detalles ---
  // Usuario selecciona una serie -> Update selected ID
  on(SeriesEvents.seriesSelected, ({ payload: { theTvDbId } }) => ({
    selectedId: theTvDbId,
  })),
  // API retorna detalles -> Store details
  on(SeriesApiEvents.detailLoadedSuccess, ({ payload: seriesDetail }) => ({
    seriesDetail,
  }))
),

Nota Técnica: La función que pasamos a on(...) recibe dos cosas: el evento (con su payload) y el estado actual. Su único trabajo es devolver el nuevo estado (o la parte que cambió).

4. Procesamiento de Eventos (Event Handlers)

Luego, escuchamos interacciones del usuario para disparar efectos secundarios (como llamadas a API). Usamos withEventHandlers para capturar los eventos.

Nota: En NgRx v21, withEffects fue renombrado a withEventHandlers. Asegúrate de estar usando NgRx v21 o superior.

Nota algo importante: No actualizamos el estado aquí. Simplemente disparamos el efecto secundario y despachamos el resultado como un nuevo evento.

// Dentro de signalStore...
withEventHandlers((store, events = inject(Events), seriesService = inject(SeriesService)) => ({
  loadSeriesByQuery$: events
    .on(SeriesEvents.queryChanged)
    .pipe(
      // 1. Debounce para evitar saturar la API
      debounceTime(300),
      // 2. Llamar a la API
      switchMap(({ payload }) =>
        seriesService.searchSeries(payload.query).pipe(
          // 3. Mapear resultado a eventos de Éxito/Fallo
          mapResponse({
            next: (series) => SeriesApiEvents.loadedSuccess(series),
            error: (e: Error) => SeriesApiEvents.loadedFailure(e.message),
          }),
        ),
      ),
    ),
  loadSeriesDetail$: events
    .on(SeriesEvents.seriesSelected)
    .pipe(
      filter(({ payload }) => !!payload.theTvDbId),
      switchMap(({ payload }) =>
        seriesService.getSeriesDetail(payload.theTvDbId).pipe(
          mapResponse({
            next: (detail) => SeriesApiEvents.detailLoadedSuccess(detail),
            error: (e: Error) => SeriesApiEvents.detailLoadedFailure(e.message),
          }),
        ),
      ),
    ),
})),

5. Despachando desde Componentes

El SearchContainerComponent conecta nuestros componentes de presentación (dumb components) al store y despacha eventos.

@Component({
  // ... imports y providers
  template: `
    <app-search [state]="store.searchState()" (searchQuery)="searchSeries($event)" />
    <app-results [series]="store.series()" [state]="store.searchState()" (selected)="onSeriesSelected($event)" />
    <nz-drawer [nzVisible]="isDrawerVisible()" ...>
      <app-series-detail ... />
    </nz-drawer>
  `,
})
export class SearchContainerComponent {
  readonly store = inject(SeriesStore);
  private readonly dispatch = injectDispatch(SeriesEvents);

  isDrawerVisible = computed(() => !!this.store.selectedId());

  searchSeries(formValue: string) {
    this.dispatch.queryChanged({ query: formValue });
  }

  onSeriesSelected(serie: Serie) {
    this.dispatch.seriesSelected({ theTvDbId: serie.externals.thetvdb });
  }
}

Observa la simplicidad resultante en el componente:

  • Usamos injectDispatch para obtener un despachador para nuestros SeriesEvents específicos.
  • Actuamos como un puente: Leemos del store (signals), Escribimos vía eventos.
  • No llamamos a métodos como store.loadSeries(). Simplemente anunciamos: "La query cambió".

Este enfoque nos da un Inventario de Eventos claro de nuestra aplicación. Podemos agrupar y organizar estos eventos de una manera que tenga sentido para el dominio de negocio, en lugar de estar atados a detalles de implementación.

6. Eventos con Scope (Nuevo en NgRx 21)

Por defecto, los servicios Dispatcher y Events operan en un alcance global donde todos los eventos despachados se manejan en toda la aplicación. Sin embargo, NgRx v21 introduce Scoped Events (Eventos con Alcance), que son cruciales para el aislamiento en escenarios como Micro-Frontends o subárboles de funcionalidades específicas.

Puedes configurar el alcance al inyectar el servicio Events o Dispatcher:

  • self (default): Un evento despachado y manejado solo dentro del alcance local.
  • parent: Un evento es reenviado al despachador padre.
  • global: Un evento es reenviado al despachador global.

De la documentación: "En algunos casos, el manejo de eventos debe aislarse a una funcionalidad o subárbol de componentes particular. Ejemplos típicos incluyen escenarios de gestión de estado local donde los eventos deben permanecer dentro de una funcionalidad específica, o arquitecturas de micro-frontend donde cada módulo remoto necesita su propio alcance de eventos aislado."

// Ejemplo de inyección con scope
const dispatch = injectDispatch(SeriesEvents, { scope: "self" });

Por qué usar SignalStore Events

Podrías estar preguntándote, "¿Por qué añadir este boilerplate extra?"

Es una pregunta válida. Para funcionalidades simples, el SignalStore estándar basado en métodos es mi opción preferida. Sin embargo, el plugin de Eventos brilla cuando:

  • Cadenas Complejas: Una acción dispara múltiples actualizaciones independientes a través de diferentes partes del estado.
  • Desacoplamiento: Quieres que tus "Smart Components" sean aún más tontos. Solo anuncian "Usuario hizo clic en Guardar" sin saber qué implica "Guardar".
  • Orquestación: Necesitas coordinar flujos entre múltiples stores.
  • Mantenibilidad y Claridad: Conforme crece la funcionalidad, es mucho más sencillo rastrear la lógica siguiendo una cadena de eventos que navegando por múltiples llamadas a métodos anidados. Esta estructura brilla en la complejidad: tu "yo del futuro" agradecerá este orden dentro de 6 meses. Por algo el patrón Redux es un estándar empresarial; la disciplina que impone se traduce en estabilidad a gran escala.

Conclusión

NgRx SignalStore Events concilia dos mundos: la estructura predecible de Redux y la ergonomía moderna de Signals. Con su madurez en la versión 21 y la flexibilidad de los Scoped Events, hoy es una opción robusta para arquitecturas escalables.

Esta herramienta ofrece una gestión limpia y reactiva de flujos complejos, preservando la excelente experiencia de desarrollo (DX) que caracteriza a Signals. Al separar estrictamente la intención (eventos) de las transiciones de estado (reducers) y los efectos secundarios (handlers), ganamos una claridad y mantenibilidad invaluables conforme escala nuestra aplicación.

Revisa el código completo en el repositorio.

Referencias

Logo
Arcadio QuinteroSystems Engineer

Compartiendo conocimiento práctico sobre arquitectura de software, mejores prácticas de Angular y el panorama evolutivo del desarrollo web.

© 2026 Arcadio Quintero. Todos los derechos reservados.

Construido conAnalog&Angular