Codea Bien Logo

Explorando NgRx Signal Store

El ecosistema de Angular está en constante evolución, y la introducción de los Signals ha sido uno de los cambios más significativos y bienvenidos. Con ellos, llega una nueva forma de pensar en la reactividad y, por supuesto, en la gestión del estado. NgRx, el estándar de facto para la gestión de estado en aplicaciones Angular complejas, ha respondido a este cambio con una solución moderna y elegante: NgRx Signal Store.

En este artículo, exploraremos qué es Signal Store, cómo funciona y cómo puedes empezar a utilizarlo para crear una gestión de estado más ligera, intuitiva y con mucho menos boilerplate.

Para una referencia más profunda, siempre puedes consultar la documentación oficial de NgRx Signal Store.

¿Qué es NgRx Signal Store?

NgRx Signal Store es una solución de gestión de estado ligera construida sobre Angular Signals. Su objetivo es proporcionar una forma reactiva, sencilla y fuertemente tipada de manejar el estado de tus aplicaciones, tanto a nivel global como local en un componente.

Olvida por un momento los actions, reducers y effects tradicionales. Signal Store ofrece una API más directa y funcional que se integra a la perfección con el nuevo paradigma de Signals.

Instalación

Integrar Signal Store en tu proyecto es muy sencillo. Solo necesitas instalar el paquete @ngrx/signals:

npm install @ngrx/signals

¡Y eso es todo! No necesitas instalar @ngrx/store ni ningún otro paquete de NgRx para empezar a usarlo. Signal Store puede funcionar de manera completamente independiente.

Uso: Los Pilares del Signal Store

La magia de Signal Store reside en su API composable, que se basa en la función signalStore y una serie de funciones auxiliares como withState, withComputed, withMethods y withHooks.

Veamos cómo funciona cada una.

1. Creando el Store con signalStore y withState

Todo comienza con la función signalStore. Para definir el estado inicial, usamos la función withState.

withState toma un objeto que representa la forma (shape) de tu estado. Internamente, convierte cada propiedad de este objeto en un signal.

Imaginemos que queremos gestionar una lista de tareas (todos). Nuestro store inicial se vería así:

// todos.store.ts
import { signalStore, withState } from '@ngrx/signals';

// 1. Define the shape of your state
export interface TodosState {
  todos: { id: number; text: string; completed: boolean }[];
  isLoading: boolean;
  filter: 'all' | 'pending' | 'completed';
}

// 2. Define the initial state
const initialState: TodosState = {
  todos: [],
  isLoading: false,
  filter: 'all',
};

// 3. Create the store
export const TodosStore = signalStore(
  { providedIn: 'root' }, // Optional: Makes the store a root-level provider
  withState(initialState)
);

Ahora, TodosStore es una clase inyectable que contiene tres signals: todos(), isLoading() y filter().

2. Estado Derivado con withComputed

A menudo necesitamos calcular valores a partir de nuestro estado existente (por ejemplo, filtrar una lista). Para esto, usamos withComputed. Funciona de manera muy similar a los computed signals de Angular y es extremadamente eficiente, ya que solo se recalcula cuando una de sus dependencias cambia.

Agreguemos un estado computado para filtrar los todos:

// todos.store.ts
import { computed } from '@angular/core';
import { signalStore, withState, withComputed } from '@ngrx/signals';
// ... (initialState and TodosState interface)

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  // Add computed signals
  withComputed(({ todos, filter }) => ({
    filteredTodos: computed(() => {
      const allTodos = todos(); // Get the value from the signal
      switch (filter()) {
        case 'pending':
          return allTodos.filter(t => !t.completed);
        case 'completed':
          return allTodos.filter(t => t.completed);
        default:
          return allTodos;
      }
    }),
    todosCount: computed(() => todos().length),
  }))
);

Ahora nuestro store también expone filteredTodos() y todosCount(), dos signals de solo lectura que se actualizan automáticamente.

3. Modificando el Estado con withMethods y patchState

Para modificar el estado, no despachamos acciones. En su lugar, definimos métodos dentro del store usando withMethods. Estos métodos utilizan la función patchState para actualizar el estado de manera inmutable.

withMethods recibe una función que tiene acceso al store completo, permitiéndonos llamar a otros métodos, acceder al estado y, lo más importante, modificarlo.

// todos.store.ts
import { patchState } from '@ngrx/signals';
// ... other imports

// A dummy service to simulate an API call
class TodosService {
  async getTodos() {
    return [{ id: 1, text: 'Learn Signal Store', completed: false }];
  }
}

export const TodosStore = signalStore(
  // ... withState, withComputed
  withMethods((store, todosService = inject(TodosService)) => ({
    // Method to update a simple value
    updateFilter(newFilter: 'all' | 'pending' | 'completed'): void {
      patchState(store, { filter: newFilter });
    },

    // Method to add a new item to an array
    addTodo(text: string): void {
      const newTodo = {
        id: Date.now(),
        text,
        completed: false,
      };
      patchState(store, { todos: [...store.todos(), newTodo] });
    },

    // Async method for side effects
    async loadTodos(): Promise<void> {
      patchState(store, { isLoading: true });
      const todos = await todosService.getTodos();
      patchState(store, { todos, isLoading: false });
    },
  }))
);

Observa cómo patchState nos permite actualizar solo las partes del estado que necesitamos, de forma segura y predecible.

4. Efectos Secundarios con withHooks

Para ejecutar lógica cuando el store se inicializa o se destruye (por ejemplo, para cargar datos iniciales), podemos usar withHooks.

// todos.store.ts
// ... imports

export const TodosStore = signalStore(
  // ... withState, withComputed, withMethods
  withHooks({
    onInit({ loadTodos }) {
      // 'loadTodos' is one of the methods we defined
      console.log('TodosStore initialized. Loading initial data...');
      loadTodos();
    },
    onDestroy() {
      console.log('TodosStore destroyed.');
    }
  })
);

onInit se ejecuta una vez que el store es instanciado, haciéndolo el lugar perfecto para cargar datos iniciales.

Ejemplo Completo: Poniéndolo todo junto

Ahora que conocemos las piezas, veamos cómo usar nuestro TodosStore en un componente de Angular.

1. El Store Completo (todos.store.ts)

Juntando todo lo que hemos visto, nuestro store se ve así:

// src/app/todos.store.ts
import { computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withHooks, withMethods, withState } from '@ngrx/signals';

// Mock Service
export class TodosService {
  async getTodos(): Promise<Todo[]> {
    // Simulate API delay
    await new Promise(resolve => setTimeout(resolve, 500));
    return [
      { id: 1, text: 'Learn Angular Signals', completed: true },
      { id: 2, text: 'Try NgRx Signal Store', completed: false },
    ];
  }
}

// State Definition
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export interface TodosState {
  todos: Todo[];
  isLoading: boolean;
  filter: 'all' | 'pending' | 'completed';
}

const initialState: TodosState = {
  todos: [],
  isLoading: false,
  filter: 'all',
};

// Store Definition
export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ todos, filter }) => ({
    filteredTodos: computed(() => {
      const allTodos = todos();
      switch (filter()) {
        case 'pending':
          return allTodos.filter((t) => !t.completed);
        case 'completed':
          return allTodos.filter((t) => t.completed);
        default:
          return allTodos;
      }
    }),
  })),
  withMethods((store, todosService = inject(TodosService)) => ({
    async loadTodos(): Promise<void> {
      patchState(store, { isLoading: true });
      const todos = await todosService.getTodos();
      patchState(store, { todos, isLoading: false });
    },
    addTodo(text: string): void {
      const newTodo = { id: Date.now(), text, completed: false };
      patchState(store, (state) => ({ todos: [...state.todos, newTodo] }));
    },
    updateFilter(filter: TodosState['filter']): void {
        patchState(store, { filter });
    }
  })),
  withHooks({
    onInit({ loadTodos }) {
      loadTodos();
    },
  })
);

2. Usando el Store en un Componente (todos-list.component.ts)

Gracias a { providedIn: 'root' }, podemos inyectar nuestro store directamente en cualquier componente.

// src/app/todos-list.component.ts
import { Component, inject } from '@angular/core';
import { TodosStore } from './todos.store';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-todos-list',
  standalone: true,
  imports: [JsonPipe],
  // The store is provided at root, but can also be provided here:
  // providers: [TodosStore],
  template: &#96;
    <h2>My Todos</h2>
    @if (store.isLoading()) {
      <p>Loading...</p>
    } @else {
      <div>
        <button (click)="store.updateFilter('all')">All</button>
        <button (click)="store.updateFilter('pending')">Pending</button>
        <button (click)="store.updateFilter('completed')">Completed</button>
      </div>

      <ul>
        @for (todo of store.filteredTodos(); track todo.id) {
          <li>{{ todo.text }} - {{ todo.completed ? 'Done' : 'Pending' }}</li>
        }
      </ul>
      
      <button (click)="store.addTodo('A new awesome task!')">Add Task</button>
    }

    <hr>
    <h3>Raw State:</h3>
    <pre>{{ store() | json }}</pre>
  &#96;
})
export class TodosListComponent {
  readonly store = inject(TodosStore);
}

¡Y listo! Tenemos un componente totalmente reactivo. El template se actualiza automáticamente cada vez que un signal del que depende cambia su valor. La lógica de negocio está encapsulada en el store, y el componente es simple y declarativo.

Conclusiones

NgRx Signal Store representa un paso adelante en la gestión de estado en Angular. Sus principales ventajas son:

  1. Menos Boilerplate: Adiós a las acciones, reductores, selectores y efectos como archivos separados. Todo está cohesionado en una única definición de store.

  2. API Intuitiva: El enfoque composable con withState, withComputed y withMethods es fácil de entender y de usar.

  3. Fuertemente Tipado: Obtienes autocompletado y seguridad de tipos en todo momento, desde el estado hasta los métodos.

  4. Rendimiento: Al estar construido sobre Signals, se beneficia de la detección de cambios granular y eficiente de Angular.

  5. Flexibilidad: Es ideal tanto para el estado global de la aplicación como para el estado local de un componente complejo, simplemente ajustando dónde lo provees.

No es necesariamente un reemplazo para el NgRx Store tradicional basado en Redux, que sigue siendo una solución excelente para arquitecturas de eventos muy complejas. Sin embargo, para la gran mayoría de las aplicaciones, NgRx Signal Store ofrece una alternativa más simple, moderna y productiva.

Te animo a que lo pruebes en tu próximo proyecto o a que refactorices un componente existente. ¡Es probable que te encante la experiencia!

Para seguir aprendiendo, visita la guía oficial de NgRx Signals.