NgRx v20: La Evolución del Estado con Events, SignalStore Mejorado y Developer Experience Revolucionaria
NgRx v20 representa un hito transformador en la gestión de estado para aplicaciones Angular, alineándose perfectamente con el lanzamiento de Angular v20. Esta versión marca la maduración definitiva de @ngrx/signals, transformándolo de una biblioteca prometedora en una solución completa de gestión de estado capaz de manejar desde el estado de componentes más simple hasta las aplicaciones empresariales más complejas.
Los Pilares de NgRx v20
🚀 Características Principales
- Arquitectura Basada en Eventos: Plugin Events experimental con patrones Flux modernos
- SignalStore Mejorado: Gestión de entidades mejorada y composición avanzada
- Developer Experience Superior: Testing simplificado y herramientas mejoradas
- Compatibilidad Total: Integración perfecta con Angular v20 y Signals
Arquitectura Moderna con el Plugin Events
¿Qué es el Plugin Events?
NgRx v20 introduce el plugin Events para SignalStore en fase experimental, proporcionando una arquitectura desacoplada basada en eventos inspirada en Flux. Mientras que el enfoque tradicional basado en métodos es perfecto para muchos escenarios, aplicaciones complejas se benefician de una arquitectura más desacoplada.
Comparación: Métodos vs Eventos
| Característica | SignalStore Clásico (Métodos) | SignalStore con Events |
|---|---|---|
| Invocación | store.loadProducts() | dispatcher.dispatch(productEvents.load()) |
| Acoplamiento | Fuertemente acoplado | Desacoplado |
| Cambio de Estado | Dentro del método con patchState | En bloque withReducer escuchando eventos |
| Efectos Secundarios | Dentro del método con rxMethod | En bloque withEffects escuchando eventos |
| Caso de Uso | Estado local/feature | Comunicación inter-store compleja |
Implementación con Eventos
// Definición de eventos para productos
const productEvents = {
load: () => ({ type: '[Products] Load' as const }),
loadSuccess: (products: Product[]) => ({
type: '[Products] Load Success' as const,
products
}),
loadFailure: (error: string) => ({
type: '[Products] Load Failure' as const,
error
}),
add: (product: Product) => ({
type: '[Products] Add' as const,
product
}),
remove: (productId: string) => ({
type: '[Products] Remove' as const,
productId
})
};
// Store con arquitectura basada en eventos
const ProductsStore = signalStore(
withState({
products: [] as Product[],
loading: false,
error: null as string | null
}),
// Manejo de eventos con reducers
withReducer(
on(productEvents.load, (state) => ({
...state,
loading: true,
error: null
})),
on(productEvents.loadSuccess, (state, { products }) => ({
...state,
products,
loading: false
})),
on(productEvents.loadFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
on(productEvents.add, (state, { product }) => ({
...state,
products: [...state.products, product]
})),
on(productEvents.remove, (state, { productId }) => ({
...state,
products: state.products.filter(p => p.id !== productId)
}))
),
// Efectos secundarios con eventos
withEffects((store, productsApi = inject(ProductsApi)) => ({
loadProducts$: createEffect(() =>
store.events$.pipe(
ofType(productEvents.load),
switchMap(() =>
productsApi.getProducts().pipe(
map(products => productEvents.loadSuccess(products)),
catchError(error => of(productEvents.loadFailure(error.message)))
)
)
)
)
}))
);
Uso en Componentes
@Component({
selector: 'app-product-list',
template: `
<div class="product-container">
@if (store.loading()) {
<div class="loading">Cargando productos...</div>
}
@if (store.error()) {
<div class="error">Error: {{ store.error() }}</div>
}
<div class="products-grid">
@for (product of store.products(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>{{ product.price | currency }}</p>
<button (click)="removeProduct(product.id)">
Eliminar
</button>
</div>
}
</div>
<button (click)="loadProducts()" [disabled]="store.loading()">
Cargar Productos
</button>
</div>
`
})
export class ProductListComponent {
store = inject(ProductsStore);
dispatcher = inject(EventDispatcher);
loadProducts() {
this.dispatcher.dispatch(productEvents.load());
}
removeProduct(productId: string) {
this.dispatcher.dispatch(productEvents.remove(productId));
}
}
Mejoras en Gestión de Entidades
Nuevos Updaters: prependEntity y upsertEntity
NgRx v20 añade dos updaters muy solicitados: prependEntity para agregar entidades al inicio de una colección, y upsertEntity para actualizar o insertar entidades.
prependEntity - Agregar al Inicio
import { signalStore, withState } from '@ngrx/signals';
import { withEntities, prependEntity } from '@ngrx/signals/entities';
interface Product {
id: string;
name: string;
price: number;
category: string;
featured: boolean;
}
const ProductsStore = signalStore(
withEntities<Product>(),
withMethods((store) => ({
addFeaturedProduct(product: Product) {
// Agregar producto destacado al inicio de la lista
patchState(store, prependEntity({
...product,
featured: true
}));
},
addNewProduct(product: Product) {
// Agregar nuevo producto al inicio para mostrarlo inmediatamente
patchState(store, prependEntity(product));
}
}))
);
// Uso en componente
@Component({
template: `
<div class="featured-products">
<h2>Productos Destacados</h2>
@for (product of featuredProducts(); track product.id) {
<div class="product-featured">
<span class="badge">NUEVO</span>
<h3>{{ product.name }}</h3>
<p>{{ product.price | currency }}</p>
</div>
}
</div>
`
})
export class FeaturedProductsComponent {
store = inject(ProductsStore);
featuredProducts = computed(() =>
this.store.entities().filter(p => p.featured)
);
addNewFeaturedProduct() {
const newProduct: Product = {
id: generateId(),
name: 'iPhone 15 Pro',
price: 1099,
category: 'Electronics',
featured: true
};
this.store.addFeaturedProduct(newProduct);
}
}
upsertEntity - Actualizar o Insertar
const ProductsStore = signalStore(
withEntities<Product>(),
withMethods((store) => ({
updateProductPrice(productId: string, newPrice: number) {
// Si el producto existe, actualiza solo el precio
// Si no existe, crea uno nuevo con el precio especificado
patchState(store, upsertEntity({
id: productId,
price: newPrice
}));
},
toggleFeatured(productId: string) {
const currentProduct = store.entityMap()[productId];
patchState(store, upsertEntity({
id: productId,
featured: !currentProduct?.featured
}));
},
updateInventory(productId: string, stock: number) {
patchState(store, upsertEntity({
id: productId,
stock,
lastUpdated: new Date()
}));
}
}))
);
// Ejemplo de uso en servicio de sincronización
@Injectable()
export class ProductSyncService {
private store = inject(ProductsStore);
private websocket = inject(WebSocketService);
ngOnInit() {
// Sincronización en tiempo real
this.websocket.connect('product-updates').subscribe(update => {
// upsertEntity maneja automáticamente crear o actualizar
this.store.updateInventory(update.productId, update.stock);
});
}
}
Composición Avanzada con withFeature
Creando Features Reutilizables
withFeature es una “factory de features” que recibe la instancia actual del store como argumento, permitiendo acceso type-safe a todos los miembros existentes del store.
// Feature genérico para carga de entidades
function withEntityLoader<Entity>(
loader: (id: string) => Observable<Entity>
) {
return signalStoreFeature(
withState({
entity: undefined as Entity | undefined,
isLoading: false,
lastLoadedId: null as string | null
}),
withMethods((store) => ({
loadEntity: rxMethod<string>(
pipe(
tap(() => patchState(store, { isLoading: true })),
switchMap((id) => loader(id).pipe(
tapResponse({
next: (entity) => patchState(store, {
entity,
isLoading: false,
lastLoadedId: id
}),
error: (error) => {
console.error('Error loading entity:', error);
patchState(store, { isLoading: false });
},
})
))
)
),
clearEntity() {
patchState(store, {
entity: undefined,
lastLoadedId: null
});
}
}))
);
}
// Store de productos usando el feature genérico
const ProductsStore = signalStore(
withEntities<Product>(),
withMethods((store, productsApi = inject(ProductsApi)) => ({
// Implementación específica del método de carga
loadProduct(id: string): Observable<Product> {
return productsApi.fetchProductById(id);
},
loadProductWithReviews(id: string): Observable<Product> {
return productsApi.fetchProductWithReviews(id);
}
})),
// Conectar el feature genérico con el método específico del store
withFeature((store) =>
withEntityLoader((id: string) => store.loadProduct(id))
)
);
Feature de Filtrado Genérico
function withProductsFilter(products: Signal<Product[]>) {
return signalStoreFeature(
withState({
searchQuery: '',
categoryFilter: '',
priceRange: { min: 0, max: Infinity },
sortBy: 'name' as 'name' | 'price' | 'category'
}),
withComputed(({ searchQuery, categoryFilter, priceRange, sortBy }) => ({
filteredProducts: computed(() => {
let filtered = products();
// Filtrar por búsqueda
if (searchQuery()) {
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(searchQuery().toLowerCase())
);
}
// Filtrar por categoría
if (categoryFilter()) {
filtered = filtered.filter(p => p.category === categoryFilter());
}
// Filtrar por rango de precios
filtered = filtered.filter(p =>
p.price >= priceRange().min && p.price <= priceRange().max
);
// Ordenar
return filtered.sort((a, b) => {
switch (sortBy()) {
case 'price':
return a.price - b.price;
case 'category':
return a.category.localeCompare(b.category);
default:
return a.name.localeCompare(b.name);
}
});
}),
resultsCount: computed(() => filteredProducts().length),
hasFilters: computed(() =>
!!searchQuery() || !!categoryFilter() ||
priceRange().min > 0 || priceRange().max < Infinity
)
})),
withMethods((store) => ({
setSearchQuery(query: string) {
patchState(store, { searchQuery: query });
},
setCategoryFilter(category: string) {
patchState(store, { categoryFilter: category });
},
setPriceRange(min: number, max: number) {
patchState(store, { priceRange: { min, max } });
},
setSortBy(sortBy: 'name' | 'price' | 'category') {
patchState(store, { sortBy });
},
clearFilters() {
patchState(store, {
searchQuery: '',
categoryFilter: '',
priceRange: { min: 0, max: Infinity }
});
}
}))
);
}
// Store principal con filtrado
const ProductCatalogStore = signalStore(
withEntities<Product>(),
// Usar withFeature para pasar las entidades al feature de filtrado
withFeature((store) => withProductsFilter(store.entities))
);
Reactividad Avanzada con withLinkedState
Estado Derivado Básico
const ProductsStore = signalStore(
withState({ products: [] as Product[] }),
withLinkedState(({ products }) => ({
// Selección automática del primer producto
selectedProduct: () => products()[0],
// Conteo total
totalProducts: () => products().length,
// Categorías únicas
availableCategories: () => [
...new Set(products().map(p => p.category))
]
}))
);
Estado Derivado Avanzado con linkedSignal
type ProductOption = { id: string; name: string; price: number };
const ProductOptionsStore = signalStore(
withState({
products: [] as ProductOption[],
lastSelectedId: null as string | null
}),
withLinkedState(({ products, lastSelectedId }) => ({
selectedProduct: linkedSignal<ProductOption[], ProductOption>({
source: products,
computation: (newProducts, previous) => {
// Intentar mantener la selección previa
if (previous?.value && lastSelectedId) {
const stillExists = newProducts.find(p => p.id === lastSelectedId());
if (stillExists) return stillExists;
}
// Fallback al primer producto disponible
return newProducts[0];
}
})
})),
withMethods((store) => ({
selectProduct(productId: string) {
const product = store.products().find(p => p.id === productId);
if (product) {
patchState(store, { lastSelectedId: productId });
}
},
updateProducts(products: ProductOption[]) {
patchState(store, { products });
}
}))
);
// Uso en componente
@Component({
selector: 'app-product-selector',
template: `
<div class="product-selector">
<h3>Seleccionar Producto</h3>
<div class="current-selection" *ngIf="store.selectedProduct()">
<h4>Seleccionado:</h4>
<p>{{ store.selectedProduct().name }} -
{{ store.selectedProduct().price | currency }}</p>
</div>
<div class="product-options">
@for (product of store.products(); track product.id) {
<button
class="product-option"
[class.selected]="product.id === store.selectedProduct()?.id"
(click)="store.selectProduct(product.id)">
{{ product.name }}
</button>
}
</div>
</div>
`
})
export class ProductSelectorComponent {
store = inject(ProductOptionsStore);
ngOnInit() {
// Simular actualización de productos
this.store.updateProducts([
{ id: '1', name: 'iPhone 15', price: 999 },
{ id: '2', name: 'Samsung Galaxy', price: 899 },
{ id: '3', name: 'Google Pixel', price: 799 }
]);
}
}
Testing Simplificado
Nueva Biblioteca @ngrx/signals/testing
NgRx v20 introduce el helper unprotected que permite bypasear la encapsulación de estado durante las pruebas.
// product.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
import { ProductsStore } from './products.store';
describe('ProductsStore', () => {
let store: ProductsStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProductsStore]
});
store = TestBed.inject(ProductsStore);
});
it('should calculate total value correctly', () => {
const mockProducts = [
{ id: '1', name: 'iPhone', price: 999, quantity: 2 },
{ id: '2', name: 'iPad', price: 799, quantity: 1 }
];
// Usar unprotected para setup de test
patchState(unprotected(store), { products: mockProducts });
// Verificar computed signal
expect(store.totalValue()).toBe(2797); // (999 * 2) + (799 * 1)
});
it('should filter products by category', () => {
const mockProducts = [
{ id: '1', name: 'iPhone', price: 999, category: 'Electronics' },
{ id: '2', name: 'T-Shirt', price: 29, category: 'Clothing' },
{ id: '3', name: 'iPad', price: 799, category: 'Electronics' }
];
patchState(unprotected(store), {
products: mockProducts,
categoryFilter: 'Electronics'
});
const filtered = store.filteredProducts();
expect(filtered).toHaveLength(2);
expect(filtered.every(p => p.category === 'Electronics')).toBe(true);
});
it('should handle product selection', () => {
const mockProducts = [
{ id: '1', name: 'iPhone', price: 999 },
{ id: '2', name: 'iPad', price: 799 }
];
patchState(unprotected(store), { products: mockProducts });
store.selectProduct('2');
expect(store.selectedProduct()?.id).toBe('2');
expect(store.selectedProduct()?.name).toBe('iPad');
});
});
Testing de Features Compuestos
// product-catalog.store.spec.ts
describe('ProductCatalogStore with Features', () => {
let store: ProductCatalogStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProductCatalogStore]
});
store = TestBed.inject(ProductCatalogStore);
});
it('should handle complex filtering scenarios', () => {
const mockProducts = [
{ id: '1', name: 'iPhone 15', price: 999, category: 'Electronics' },
{ id: '2', name: 'iPad Pro', price: 1099, category: 'Electronics' },
{ id: '3', name: 'MacBook', price: 1999, category: 'Computers' },
{ id: '4', name: 'iPhone 14', price: 799, category: 'Electronics' }
];
patchState(unprotected(store), { entities: mockProducts });
// Test múltiples filtros
store.setSearchQuery('iPhone');
store.setCategoryFilter('Electronics');
store.setPriceRange(800, 1200);
const result = store.filteredProducts();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('iPhone 15');
});
it('should maintain selection after product updates', () => {
const initialProducts = [
{ id: '1', name: 'iPhone 15', price: 999 },
{ id: '2', name: 'iPad', price: 799 }
];
patchState(unprotected(store), {
products: initialProducts,
lastSelectedId: '1'
});
// Verificar selección inicial
expect(store.selectedProduct()?.id).toBe('1');
// Actualizar productos manteniendo el seleccionado
const updatedProducts = [
{ id: '1', name: 'iPhone 15 Pro', price: 1099 }, // Actualizado
{ id: '3', name: 'MacBook', price: 1999 } // Nuevo
];
store.updateProducts(updatedProducts);
// Verificar que se mantiene la selección
expect(store.selectedProduct()?.id).toBe('1');
expect(store.selectedProduct()?.name).toBe('iPhone 15 Pro');
});
});
Integración con Angular v20
Compatibilidad con Signals
@Component({
selector: 'app-product-dashboard',
template: `
<div class="dashboard">
<!-- Integración directa con Signals de Angular -->
<div class="stats">
<div class="stat-card">
<h3>Total Productos</h3>
<span>{{ store.totalProducts() }}</span>
</div>
<div class="stat-card">
<h3>Valor Total</h3>
<span>{{ store.totalValue() | currency }}</span>
</div>
<div class="stat-card">
<h3>Categorías</h3>
<span>{{ store.availableCategories().length }}</span>
</div>
</div>
<!-- Control Flow nativo de Angular 20 -->
@if (store.loading()) {
<div class="loading-spinner">Cargando productos...</div>
}
@if (store.error()) {
<div class="error-banner">
Error: {{ store.error() }}
<button (click)="store.clearError()">Cerrar</button>
</div>
}
<!-- Lista de productos con @for -->
<div class="products-container">
@for (product of store.filteredProducts(); track product.id) {
<div class="product-card"
[class.selected]="product.id === store.selectedProduct()?.id">
<h4>{{ product.name }}</h4>
<p class="price">{{ product.price | currency }}</p>
<p class="category">{{ product.category }}</p>
@if (product.stock <= 5) {
<span class="low-stock">Stock bajo: {{ product.stock }}</span>
}
<button (click)="store.selectProduct(product.id)">
Seleccionar
</button>
</div>
} @empty {
<div class="no-products">
No se encontraron productos
</div>
}
</div>
</div>
`
})
export class ProductDashboardComponent {
store = inject(ProductsStore);
// Computed signals locales que reaccionan al store
totalRevenue = computed(() =>
this.store.entities()
.filter(p => p.sold > 0)
.reduce((sum, p) => sum + (p.price * p.sold), 0)
);
lowStockProducts = computed(() =>
this.store.entities().filter(p => p.stock <= 5)
);
ngOnInit() {
// Efecto para notificaciones de stock bajo
effect(() => {
const lowStock = this.lowStockProducts();
if (lowStock.length > 0) {
console.warn(`${lowStock.length} productos con stock bajo`);
}
});
}
}
Optimización para Zoneless
@Component({
selector: 'app-product-list-optimized',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="optimized-product-list">
<!-- Los signals activan cambios automáticamente en Zoneless -->
@for (product of store.paginatedProducts(); track product.id) {
<app-product-item
[product]="product"
[selected]="product.id === store.selectedProduct()?.id"
(select)="store.selectProduct(product.id)"
(addToCart)="store.addToCart(product.id)" />
}
<!-- Paginación reactiva -->
<div class="pagination">
<button
[disabled]="!store.hasPreviousPage()"
(click)="store.previousPage()">
Anterior
</button>
<span>
Página {{ store.currentPage() }} de {{ store.totalPages() }}
</span>
<button
[disabled]="!store.hasNextPage()"
(click)="store.nextPage()">
Siguiente
</button>
</div>
</div>
`
})
export class OptimizedProductListComponent {
store = inject(ProductsStore);
}
Migración y Adopción
Estrategia de Migración Gradual
// Paso 1: Mantener ComponentStore existente
@Injectable()
export class LegacyProductsComponentStore extends ComponentStore<ProductsState> {
// Lógica existente...
}
// Paso 2: Crear SignalStore paralelo
const NewProductsStore = signalStore(
{ protectedState: false }, // Para testing más fácil durante migración
withEntities<Product>(),
withMethods((store) => ({
// Migrar métodos gradualmente
loadProducts: /* lógica migrada */,
addProduct: /* lógica migrada */
}))
);
// Paso 3: Servicio de transición
@Injectable()
export class ProductsStoreService {
private legacyStore = inject(LegacyProductsComponentStore);
private newStore = inject(NewProductsStore);
// Flag para alternar entre stores
private useNewStore = inject(FEATURE_FLAGS).useSignalStore;
get products$() {
return this.useNewStore
? toObservable(this.newStore.entities)
: this.legacyStore.products$;
}
loadProducts() {
return this.useNewStore
? this.newStore.loadProducts()
: this.legacyStore.loadProducts();
}
}
Configuración de ESLint
// .eslintrc.js
module.exports = {
extends: ['@ngrx/eslint-plugin/all'],
rules: {
// Nueva regla para type invocation explícito
'@ngrx/explicit-type-invocation': 'error',
'@ngrx/signal-store-feature-should-use-generic-type': 'error'
}
};
Casos de Uso Avanzados
E-commerce con Carrito de Compras
interface CartItem {
productId: string;
quantity: number;
addedAt: Date;
}
interface Product {
id: string;
name: string;
price: number;
category: string;
stock: number;
imageUrl: string;
}
const EcommerceStore = signalStore(
// Estado de productos
withEntities<Product>(),
// Estado de carrito
withState({
cartItems: [] as CartItem[],
checkoutInProgress: false,
lastOrder: null as Order | null
}),
// Computeds para carrito
withComputed((store) => ({
cartProducts: computed(() => {
const products = store.entityMap();
return store.cartItems().map(item => ({
...products[item.productId],
quantity: item.quantity,
subtotal: products[item.productId].price * item.quantity
}));
}),
cartTotal: computed(() =>
store.cartProducts().reduce((sum, item) => sum + item.subtotal, 0)
),
cartItemsCount: computed(() =>
store.cartItems().reduce((sum, item) => sum + item.quantity, 0)
),
canCheckout: computed(() =>
store.cartItems().length > 0 && !store.checkoutInProgress()
)
})),
// Métodos para gestión de carrito
withMethods((store, checkoutService = inject(CheckoutService)) => ({
addToCart(productId: string, quantity: number = 1) {
const existingItem = store.cartItems().find(item => item.productId === productId);
if (existingItem) {
// Actualizar cantidad existente
patchState(store, {
cartItems: store.cartItems().map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + quantity }
: item
)
});
} else {
// Agregar nuevo item
patchState(store, {
cartItems: [...store.cartItems(), {
productId,
quantity,
addedAt: new Date()
}]
});
}
},
removeFromCart(productId: string) {
patchState(store, {
cartItems: store.cartItems().filter(item => item.productId !== productId)
});
},
updateQuantity(productId: string, quantity: number) {
if (quantity <= 0) {
this.removeFromCart(productId);
return;
}
patchState(store, {
cartItems: store.cartItems().map(item =>
item.productId === productId
? { ...item, quantity }
: item
)
});
},
clearCart() {
patchState(store, { cartItems: [] });
},
checkout: rxMethod<void>(
pipe(
tap(() => patchState(store, { checkoutInProgress: true })),
switchMap(() => {
const order = {
items: store.cartProducts(),
total: store.cartTotal(),
timestamp: new Date()
};
return checkoutService.processOrder(order).pipe(
tapResponse({
next: (completedOrder) => {
patchState(store, {
checkoutInProgress: false,
lastOrder: completedOrder,
cartItems: []
});
},
error: (error) => {
console.error('Checkout failed:', error);
patchState(store, { checkoutInProgress: false });
}
})
);
})
)
)
})),
// Feature de filtrado reutilizable
withFeature((store) => withProductsFilter(store.entities))
);
Dashboard de Inventario
interface InventoryMetrics {
totalProducts: number;
lowStockCount: number;
outOfStockCount: number;
totalValue: number;
topSellingCategory: string;
}
const InventoryDashboardStore = signalStore(
withEntities<Product>(),
withState({
metrics: null as InventoryMetrics | null,
loading: false,
lastUpdated: null as Date | null,
autoRefresh: true
}),
withComputed((store) => ({
lowStockProducts: computed(() =>
store.entities().filter(p => p.stock <= 5 && p.stock > 0)
),
outOfStockProducts: computed(() =>
store.entities().filter(p => p.stock === 0)
),
categorySales: computed(() => {
const categories = new Map<string, number>();
store.entities().forEach(product => {
const current = categories.get(product.category) || 0;
categories.set(product.category, current + (product.sold || 0));
});
return Array.from(categories.entries())
.sort(([,a], [,b]) => b - a);
}),
inventoryHealth: computed(() => {
const total = store.entities().length;
const lowStock = store.lowStockProducts().length;
const outOfStock = store.outOfStockProducts().length;
if (outOfStock > total * 0.1) return 'critical';
if (lowStock > total * 0.2) return 'warning';
return 'healthy';
})
})),
withMethods((store, inventoryApi = inject(InventoryApi)) => ({
refreshMetrics: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap(() =>
inventoryApi.getMetrics().pipe(
tapResponse({
next: (metrics) => patchState(store, {
metrics,
loading: false,
lastUpdated: new Date()
}),
error: (error) => {
console.error('Failed to load metrics:', error);
patchState(store, { loading: false });
}
})
)
)
)
),
toggleAutoRefresh() {
patchState(store, { autoRefresh: !store.autoRefresh() });
},
restockProduct: rxMethod<{id: string, quantity: number}>(
pipe(
switchMap(({id, quantity}) =>
inventoryApi.restockProduct(id, quantity).pipe(
tapResponse({
next: (updatedProduct) => {
patchState(store, updateEntity({id, changes: updatedProduct}));
},
error: (error) => console.error('Restock failed:', error)
})
)
)
)
)
}))
);
// Componente del dashboard
@Component({
selector: 'app-inventory-dashboard',
template: `
<div class="inventory-dashboard">
<div class="dashboard-header">
<h1>Dashboard de Inventario</h1>
<div class="controls">
<label>
<input
type="checkbox"
[checked]="store.autoRefresh()"
(change)="store.toggleAutoRefresh()">
Auto-refresh
</label>
<button (click)="store.refreshMetrics()" [disabled]="store.loading()">
{{ store.loading() ? 'Actualizando...' : 'Actualizar' }}
</button>
</div>
</div>
<!-- Métricas principales -->
@if (store.metrics(); as metrics) {
<div class="metrics-grid">
<div class="metric-card">
<h3>Total Productos</h3>
<span class="metric-value">{{ metrics.totalProducts }}</span>
</div>
<div class="metric-card warning" *ngIf="store.lowStockProducts().length > 0">
<h3>Stock Bajo</h3>
<span class="metric-value">{{ store.lowStockProducts().length }}</span>
</div>
<div class="metric-card critical" *ngIf="store.outOfStockProducts().length > 0">
<h3>Sin Stock</h3>
<span class="metric-value">{{ store.outOfStockProducts().length }}</span>
</div>
<div class="metric-card">
<h3>Valor Total</h3>
<span class="metric-value">{{ metrics.totalValue | currency }}</span>
</div>
</div>
}
<!-- Estado de salud del inventario -->
<div class="health-indicator" [attr.data-health]="store.inventoryHealth()">
<h3>Estado del Inventario:
<span class="health-status">{{ store.inventoryHealth() | titlecase }}</span>
</h3>
</div>
<!-- Productos con stock bajo -->
@if (store.lowStockProducts().length > 0) {
<div class="low-stock-section">
<h3>Productos con Stock Bajo</h3>
<div class="products-grid">
@for (product of store.lowStockProducts(); track product.id) {
<div class="product-card low-stock">
<h4>{{ product.name }}</h4>
<p>Stock: {{ product.stock }}</p>
<p class="category">{{ product.category }}</p>
<button (click)="restockProduct(product.id)">
Reabastecer
</button>
</div>
}
</div>
</div>
}
<!-- Top categorías -->
<div class="categories-section">
<h3>Ventas por Categoría</h3>
<div class="categories-chart">
@for (category of store.categorySales().slice(0, 5); track category[0]) {
<div class="category-bar">
<span class="category-name">{{ category[0] }}</span>
<div class="bar">
<div class="fill" [style.width.%]="(category[1] / store.categorySales()[0][1]) * 100"></div>
<span class="value">{{ category[1] }}</span>
</div>
</div>
}
</div>
</div>
<!-- Última actualización -->
@if (store.lastUpdated()) {
<div class="last-updated">
Última actualización: {{ store.lastUpdated() | date:'medium' }}
</div>
}
</div>
`,
styles: [`
.inventory-dashboard {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.metric-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
border-left: 4px solid #007bff;
}
.metric-card.warning {
border-left-color: #ffc107;
background: #fff3cd;
}
.metric-card.critical {
border-left-color: #dc3545;
background: #f8d7da;
}
.health-indicator[data-health="healthy"] {
color: #28a745;
}
.health-indicator[data-health="warning"] {
color: #ffc107;
}
.health-indicator[data-health="critical"] {
color: #dc3545;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.category-bar {
display: flex;
align-items: center;
margin: 10px 0;
}
.category-name {
width: 100px;
font-weight: 500;
}
.bar {
flex: 1;
background: #e9ecef;
height: 30px;
position: relative;
border-radius: 4px;
margin: 0 10px;
}
.fill {
background: #007bff;
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.value {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-weight: 500;
color: white;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
`]
})
export class InventoryDashboardComponent {
store = inject(InventoryDashboardStore);
ngOnInit() {
// Cargar datos iniciales
this.store.refreshMetrics();
// Auto-refresh cada 30 segundos si está habilitado
effect(() => {
if (this.store.autoRefresh()) {
const interval = setInterval(() => {
this.store.refreshMetrics();
}, 30000);
return () => clearInterval(interval);
}
});
}
restockProduct(productId: string) {
// Prompt simple para cantidad (en producción usarías un modal)
const quantity = prompt('Cantidad a reabastecer:');
if (quantity && !isNaN(+quantity)) {
this.store.restockProduct({id: productId, quantity: +quantity});
}
}
}
Mejores Prácticas y Patrones
Organización de Store
// stores/feature/products/products.store.ts
export const ProductsStore = signalStore(
{ providedIn: 'root' },
// Estados base
withEntities<Product>(),
withRequestStatus(), // Feature para loading/error states
// Features compuestas
withFeature((store) => withProductsFilter(store.entities)),
withFeature((store) => withPagination(store.filteredProducts)),
withFeature((store) => withCaching({ ttl: 300000 })), // 5 min cache
// Métodos específicos del dominio
withMethods((store, api = inject(ProductsApi)) => ({
loadProducts: rxMethod<void>(
pipe(
tap(() => store.setLoading()),
switchMap(() =>
api.getProducts().pipe(
tapResponse({
next: (products) => {
patchState(store, setAllEntities(products));
store.setLoaded();
},
error: (error) => store.setError(error.message)
})
)
)
)
),
createProduct: rxMethod<Partial<Product>>(
pipe(
switchMap((productData) =>
api.createProduct(productData).pipe(
tapResponse({
next: (product) => patchState(store, addEntity(product)),
error: (error) => store.setError(error.message)
})
)
)
)
)
}))
);
// stores/feature/products/index.ts
export { ProductsStore } from './products.store';
export type { Product } from './product.model';
export { ProductsApiService } from './products-api.service';
Features Reutilizables
// shared/store-features/with-request-status.ts
export function withRequestStatus() {
return signalStoreFeature(
withState({
loading: false,
error: null as string | null,
lastLoaded: null as Date | null
}),
withComputed(({ loading, error }) => ({
isIdle: computed(() => !loading() && !error()),
hasError: computed(() => !!error())
})),
withMethods((store) => ({
setLoading() {
patchState(store, { loading: true, error: null });
},
setLoaded() {
patchState(store, {
loading: false,
error: null,
lastLoaded: new Date()
});
},
setError(error: string) {
patchState(store, { loading: false, error });
},
clearError() {
patchState(store, { error: null });
}
}))
);
}
// shared/store-features/with-pagination.ts
export function withPagination<T>(items: Signal<T[]>, pageSize: number = 10) {
return signalStoreFeature(
withState({
currentPage: 1,
pageSize
}),
withComputed(({ currentPage, pageSize }) => ({
totalPages: computed(() => Math.ceil(items().length / pageSize())),
paginatedItems: computed(() => {
const start = (currentPage() - 1) * pageSize();
const end = start + pageSize();
return items().slice(start, end);
}),
hasPreviousPage: computed(() => currentPage() > 1),
hasNextPage: computed(() => currentPage() < Math.ceil(items().length / pageSize())),
pageInfo: computed(() => ({
current: currentPage(),
total: Math.ceil(items().length / pageSize()),
showing: Math.min(pageSize(), items().length - (currentPage() - 1) * pageSize()),
totalItems: items().length
}))
})),
withMethods((store) => ({
nextPage() {
if (store.hasNextPage()) {
patchState(store, { currentPage: store.currentPage() + 1 });
}
},
previousPage() {
if (store.hasPreviousPage()) {
patchState(store, { currentPage: store.currentPage() - 1 });
}
},
goToPage(page: number) {
const totalPages = store.totalPages();
if (page >= 1 && page <= totalPages) {
patchState(store, { currentPage: page });
}
},
setPageSize(size: number) {
patchState(store, {
pageSize: size,
currentPage: 1 // Reset to first page
});
}
}))
);
}
Integración con Arquitectura Empresarial
Store Jerárquico
// Global Application Store
const AppStore = signalStore(
{ providedIn: 'root' },
withState({
user: null as User | null,
theme: 'light' as 'light' | 'dark',
notifications: [] as Notification[]
}),
withMethods(() => ({
setUser(user: User) {
patchState(AppStore, { user });
},
toggleTheme() {
patchState(AppStore, { theme: 'light' ? 'dark' : 'light' });
}
}))
);
// Feature Store que consume del App Store
const ProductsFeatureStore = signalStore(
withEntities<Product>(),
// Inyectar dependencia del store global
withMethods((store, appStore = inject(AppStore)) => ({
loadUserProducts: rxMethod<void>(
pipe(
// Usar datos del store global
withLatestFrom(appStore.user),
filter(([, user]) => !!user),
switchMap(([, user]) =>
this.api.getUserProducts(user!.id).pipe(
tapResponse({
next: (products) => patchState(store, setAllEntities(products)),
error: (error) => console.error(error)
})
)
)
)
)
}))
);
Comunicación Entre Stores
// Event Bus para comunicación entre stores
const EventBus = signalStore(
{ providedIn: 'root' },
withState({
events$: new Subject<AppEvent>()
}),
withMethods((store) => ({
publish(event: AppEvent) {
store.events$.next(event);
},
subscribe<T extends AppEvent>(
eventType: string,
handler: (event: T) => void
) {
return store.events$.pipe(
filter(event => event.type === eventType),
tap(handler as any)
).subscribe();
}
}))
);
// Store que escucha eventos
const CartStore = signalStore(
withEntities<CartItem>(),
withMethods((store, eventBus = inject(EventBus)) => ({
ngOnInit() {
// Escuchar eventos de productos
eventBus.subscribe('product.added', (event: ProductAddedEvent) => {
this.addToCart(event.product.id, 1);
});
eventBus.subscribe('user.logout', () => {
this.clearCart();
});
},
addToCart(productId: string, quantity: number) {
// Lógica de agregar al carrito
patchState(store, addEntity({ productId, quantity }));
// Publicar evento
eventBus.publish({
type: 'cart.item.added',
productId,
quantity
});
}
}))
);
Performance y Optimización
Memoización Avanzada
const OptimizedProductStore = signalStore(
withEntities<Product>(),
withComputed((store) => ({
// Computeds memoizados automáticamente
expensiveCalculation: computed(() => {
console.log('Calculating...'); // Solo se ejecuta cuando cambian las dependencias
return store.entities()
.filter(p => p.price > 100)
.sort((a, b) => b.price - a.price)
.slice(0, 10);
}),
// Computed con dependencias específicas
categoryStats: computed(() => {
const products = store.entities();
const stats = new Map<string, {count: number, totalValue: number}>();
products.forEach(product => {
const current = stats.get(product.category) || {count: 0, totalValue: 0};
stats.set(product.category, {
count: current.count + 1,
totalValue: current.totalValue + product.price
});
});
return Array.from(stats.entries()).map(([category, data]) => ({
category,
...data,
averagePrice: data.totalValue / data.count
}));
})
})),
// Métodos optimizados
withMethods((store) => ({
// Batch updates para mejor performance
updateMultipleProducts: rxMethod<{id: string, changes: Partial<Product>}[]>(
pipe(
tap((updates) => {
// Actualizar todos en una sola transacción
patchState(store,
updates.reduce((patch, update) =>
updateEntity({id: update.id, changes: update.changes}),
{} as any
)
);
})
)
)
}))
);
Lazy Loading de Features
// Lazy loading de store features
const LazyProductStore = signalStore(
withEntities<Product>(),
// Cargar features bajo demanda
withMethods((store) => ({
async enableAdvancedFiltering() {
const { withAdvancedFiltering } = await import('./features/advanced-filtering');
// Aplicar feature dinámicamente (conceptual - requiere API futura)
return withAdvancedFiltering(store);
},
async enableAnalytics() {
const { withAnalytics } = await import('./features/analytics');
return withAnalytics(store);
}
}))
);
Futuro y Evolución
Roadmap Esperado
NgRx v20 marca el inicio de una nueva era con SignalStore mejorado y el plugin Events experimental. Las próximas versiones probablemente incluirán:
- Estabilización del Plugin Events: Transición de experimental a estable
- Mejor Integración con Router: SignalStore y navegación reactiva
- DevTools Mejoradas: Debugging específico para SignalStore
- Más Features Predefinidas: Bibliotecas de features comunes
Preparación para el Futuro
// Código preparado para futuras mejoras
const FutureReadyStore = signalStore(
// Usar las mejores prácticas actuales
withEntities<Product>(),
withRequestStatus(),
// Estructura extensible
withFeature((store) => withProductsFilter(store.entities)),
// Métodos que seguirán siendo compatibles
withMethods((store) => ({
// Lógica que escala bien
performComplexOperation: rxMethod<ComplexParams>(
pipe(
// Patrones que se mantendrán
switchMap(params => this.api.complexOperation(params)),
tapResponse({
next: result => this.handleSuccess(result),
error: error => this.handleError(error)
})
)
)
}))
);
Conclusión
NgRx v20 representa una evolución significativa en la gestión de estado para Angular, consolidando SignalStore como una solución madura y introduciendo el poderoso plugin Events. Los principales beneficios incluyen:
🎯 Beneficios Clave
- SignalStore Mejorado: Solución completa desde componentes simples hasta aplicaciones empresariales
- Arquitectura Flexible: Elección entre métodos directos o eventos desacoplados
- Developer Experience Superior: Testing simplificado y herramientas mejoradas
- Composición Avanzada: Features reutilizables con
withFeatureywithLinkedState - Performance Optimizado: Reactividad eficiente con Signals
📈 Impacto en el Desarrollo
- Menos Boilerplate: Updaters como
prependEntityyupsertEntityreducen código repetitivo - Testing Más Fácil: Helper
unprotectedsimplifica configuración de pruebas - Mejor Escalabilidad: Arquitectura basada en eventos para aplicaciones complejas
- Reutilización Mejorada: Features compuestas con
withFeature
🚀 Recomendaciones
- Proyectos Nuevos: Adoptar SignalStore desde el inicio
- Migración Gradual: Coexistir con ComponentStore durante transición
- Explorar Events: Evaluar plugin Events para aplicaciones complejas
- Aprovechar Features: Usar
withFeaturepara lógica reutilizable
NgRx v20 no solo mejora la gestión de estado, sino que establece las bases para el futuro del desarrollo Angular con una arquitectura sólida, herramientas mejoradas y una experiencia de desarrollo excepcional.
Recursos Adicionales
- Documentación Oficial: ngrx.io
- SignalStore Guide: ngrx.io/guide/signals
- Migración: Guía de actualización
- GitHub: Repositorio NgRx Platform
- Workshops: Talleres oficiales NgRx