Javi Moreno
NgRx v20: La Evolución del Estado con Events, SignalStore Mejorado y Developer Experience Revolucionaria

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ísticaSignalStore Clásico (Métodos)SignalStore con Events
Invocaciónstore.loadProducts()dispatcher.dispatch(productEvents.load())
AcoplamientoFuertemente acopladoDesacoplado
Cambio de EstadoDentro del método con patchStateEn bloque withReducer escuchando eventos
Efectos SecundariosDentro del método con rxMethodEn bloque withEffects escuchando eventos
Caso de UsoEstado local/featureComunicació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 withFeature y withLinkedState
  • Performance Optimizado: Reactividad eficiente con Signals

📈 Impacto en el Desarrollo

  • Menos Boilerplate: Updaters como prependEntity y upsertEntity reducen código repetitivo
  • Testing Más Fácil: Helper unprotected simplifica configuración de pruebas
  • Mejor Escalabilidad: Arquitectura basada en eventos para aplicaciones complejas
  • Reutilización Mejorada: Features compuestas con withFeature

🚀 Recomendaciones

  1. Proyectos Nuevos: Adoptar SignalStore desde el inicio
  2. Migración Gradual: Coexistir con ComponentStore durante transición
  3. Explorar Events: Evaluar plugin Events para aplicaciones complejas
  4. Aprovechar Features: Usar withFeature para 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