Tabla de contenidos
Angular Signals y NgRx: Estrategias Híbridas de Gestión de Estado para Apps Escalables (Guía 2026)
La gestión de estado es uno de los pilares fundamentales en el desarrollo de aplicaciones web complejas. A medida que las aplicaciones Angular evolucionan, también lo hacen las herramientas y patrones para manejar su estado de manera eficiente, performante y mantenible. En junio de 2026, nos encontramos en un punto donde dos paradigmas poderosos coexisten y se complementan: los Angular Signals y la librería NgRx. Mientras Signals se ha consolidado como la solución nativa para la reactividad granular y el estado local, NgRx sigue siendo una opción robusta para la gestión del estado global de aplicaciones a gran escala.
Este artículo explorará cómo podemos ir más allá de una elección binaria y forjar un "camino híbrido" que combine lo mejor de ambos mundos. Aprenderás a integrar Signals y NgRx de manera estratégica para construir aplicaciones Angular que no solo son altamente reactivas y eficientes, sino también escalables, predecibles y fáciles de mantener. Prepárate para dominar las estrategias que definirán la gestión de estado en Angular en los próximos años.
Entendiendo la Evolución de la Gestión de Estado en Angular
Antes de sumergirnos en el modelo híbrido, es crucial comprender el panorama y la evolución que nos ha traído hasta aquí.
El Legado de RxJS y Servicios
Durante muchos años, la piedra angular de la reactividad y la gestión de estado en Angular ha sido RxJS, una librería poderosa para la programación reactiva. Los servicios de Angular, a menudo utilizando BehaviorSubject o ReplaySubject, han sido el patrón dominante para compartir estado entre componentes y realizar operaciones asíncronas.
Este enfoque ofrece una gran flexibilidad y control sobre los flujos de datos. Sin embargo, puede introducir cierta complejidad, especialmente en aplicaciones pequeñas o medianas, debido a la necesidad de gestionar suscripciones, desuscripciones y la verbosidad de los operadores de RxJS. Además, la detección de cambios de Angular, impulsada por Zone.js, si bien potente, a veces puede ser más costosa de lo necesario en escenarios de reactividad granular.
La Revolución de Angular Signals
La introducción de Angular Signals marcó un antes y un después en la forma en que pensamos la reactividad en Angular. Signals son valores reactivos que notifican a sus "consumidores" cuando cambian. Son un mecanismo de reactividad de bajo nivel, basado en un modelo pull-based, lo que significa que los cambios se propagan de manera más eficiente y granular. Esto se traduce en:
- Reactividad granular: Solo los componentes o partes de la plantilla que dependen directamente de un Signal se actualizan, no todo el árbol.
- Rendimiento optimizado: Al evitar re-renderizados innecesarios, las aplicaciones pueden ser significativamente más rápidas.
- Simplificación del código: Eliminan gran parte de la necesidad de gestionar suscripciones de RxJS para el estado local y los efectos secundarios, haciendo el código más conciso y fácil de razonar.
- Integración con el sistema de detección de cambios de Angular: Funcionan de la mano con el sistema de detección de cambios, permitiendo un modo sin Zone.js o un control más preciso.
Un Signal se crea con signal(), un valor computado con computed() (reactivo a sus dependencias) y los efectos secundarios con effect().
import { signal, computed, effect } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
quantity: number;
}
class CartService {
products = signal<Product[]>([]);
totalItems = computed(() => this.products().reduce((sum, p) => sum + p.quantity, 0));
totalPrice = computed(() => this.products().reduce((sum, p) => sum + (p.price * p.quantity), 0));
constructor() {
effect(() => {
console.log(`El carrito tiene ${this.totalItems()} ítems con un total de ${this.totalPrice()}€`);
});
}
addProduct(product: Product) {
this.products.update(currentProducts => {
const existingProduct = currentProducts.find(p => p.id === product.id);
if (existingProduct) {
return currentProducts.map(p =>
p.id === product.id ? { ...p, quantity: p.quantity + product.quantity } : p
);
} else {
return [...currentProducts, product];
}
});
}
removeProduct(productId: number) {
this.products.update(currentProducts => currentProducts.filter(p => p.id !== productId));
}
}
NgRx: Un Poderoso Paradigma para el Estado Global
Mientras Signals brilla en la reactividad local y granular, la necesidad de una gestión de estado global predecible y depurable en aplicaciones empresariales sigue siendo crucial. Aquí es donde NgRx, inspirado en Redux, continúa ofreciendo una solución robusta.
Cuándo y Por Qué Usar NgRx
NgRx impone una arquitectura estricta de flujo de datos unidireccional (Unidirectional Data Flow) que consta de:
- Store: Un único objeto inmutable que contiene el estado de toda la aplicación.
- Actions: Objetos que describen eventos únicos que ocurren en la aplicación.
- Reducers: Funciones puras que toman el estado actual y una acción, y devuelven un nuevo estado.
- Selectors: Funciones puras para obtener partes específicas del estado del Store, optimizando la recomputación.
- Effects: Para manejar efectos secundarios asíncronos (peticiones HTTP, interacciones con APIs externas, etc.) de manera aislada.
NgRx es especialmente adecuado para:
- Aplicaciones grandes y complejas: Donde el estado se comparte entre muchos componentes y módulos.
- Colaboración en equipos grandes: La estructura y las convenciones de NgRx facilitan que varios desarrolladores trabajen en la misma base de código.
- Depuración y trazabilidad: El flujo de datos unidireccional y el historial de acciones facilitan la depuración y la comprensión de cómo cambia el estado.
- Previsibilidad: La inmutabilidad del estado y las funciones puras aseguran que el estado cambie de manera predecible.
Conceptos Clave de NgRx
En NgRx, todo comienza con una acción. Un componente o servicio dispara una acción, que luego es capturada por los reducers para actualizar el estado, y potencialmente por los effects para manejar asincronía. Los selectores permiten a los componentes "escuchar" cambios en el estado del Store.
// product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from './product.model';
export const loadProducts = createAction('[Product Page] Load Products');
export const loadProductsSuccess = createAction(
'[Product API] Load Products Success',
props<{ products: Product[] }>()
);
export const addProductToCart = createAction(
'[Product Page] Add Product To Cart',
props<{ productId: number; quantity: number }>()
);
// product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as ProductActions from './product.actions';
export interface ProductState {
products: Product[];
cart: { productId: number; quantity: number }[];
loading: boolean;
error: any;
}
export const initialProductState: ProductState = {
products: [],
cart: [],
loading: false,
error: null,
};
export const productReducer = createReducer(
initialProductState,
on(ProductActions.loadProducts, (state) => ({ ...state, loading: true, error: null })),
on(ProductActions.loadProductsSuccess, (state, { products }) => ({ ...state, products, loading: false })),
on(ProductActions.addProductToCart, (state, { productId, quantity }) => {
const existingItem = state.cart.find(item => item.productId === productId);
if (existingItem) {
return {
...state,
cart: state.cart.map(item =>
item.productId === productId ? { ...item, quantity: item.quantity + quantity } : item
)
};
} else {
return { ...state, cart: [...state.cart, { productId, quantity }] };
}
})
);
// product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState } from './product.reducer';
export const selectProductFeature = createFeatureSelector<ProductState>('products');
export const selectAllProducts = createSelector(
selectProductFeature,
(state) => state.products
);
export const selectCartItems = createSelector(
selectProductFeature,
(state) => state.cart
);
export const selectCartTotalItems = createSelector(
selectCartItems,
(cart) => cart.reduce((sum, item) => sum + item.quantity, 0)
);
El Paradigma Híbrido: Signals y NgRx Juntos
La idea central del camino híbrido es utilizar cada herramienta donde mejor se desempeña. NgRx para el estado global y complejo que se beneficia de su previsibilidad y trazabilidad, y Signals para la reactividad granular y el estado local de componentes, servicios pequeños o incluso para derivar valores del estado global de NgRx de manera eficiente.
Definición del "Camino Híbrido"
El "camino híbrido" implica una clara separación de responsabilidades:
- NgRx: Gestiona el estado de la aplicación que es verdaderamente global, compartido por múltiples módulos o componentes distantes, o que requiere un manejo de efectos secundarios complejo y centralizado (e.g., autenticación, datos maestros de la aplicación, datos de sesión).
- Angular Signals: Se encarga del estado local de un componente, de un servicio que no necesita ser global o de un módulo específico. También es excelente para derivar valores del estado de NgRx de forma reactiva y eficiente dentro de un componente.
Esta estrategia permite que los componentes sean más ligeros y fáciles de entender, ya que su estado local se maneja con Signals, mientras que las preocupaciones del estado global se delegan a NgRx, manteniendo la robustez.
Ventajas de la Combinación
- Optimización de rendimiento: Signals reduce la necesidad de re-renderizados completos en el nivel del componente, complementando el rendimiento general.
- Simplificación del código local: Menos boilerplate de RxJS para el estado de componentes.
- Previsibilidad del estado global: Se mantiene la robustez y depurabilidad de NgRx para el estado crítico.
- Curva de aprendizaje más suave: Los nuevos desarrolladores pueden comenzar con Signals para el estado local y aprender NgRx progresivamente para el estado global.
- Mayor flexibilidad: Adaptación a diferentes necesidades de estado dentro de la misma aplicación.
Implementando la Estrategia Híbrida: Ejemplos Prácticos
Veamos cómo podemos integrar ambas soluciones en escenarios reales.
Integrando Signals en un Componente con Estado Local
Supongamos que tenemos un componente de filtro de productos donde el estado de los filtros es local y no necesita ser compartido con toda la aplicación de manera global.
import { Component, signal, computed } from '@angular/core';
interface FilterOptions {
category: string;
minPrice: number;
maxPrice: number;
}
@Component({
selector: 'app-product-filter',
template: `
<div>
<input type="text" [(ngModel)]="categoryFilter" placeholder="Categoría" />
<input type="number" [(ngModel)]="minPriceFilter" placeholder="Precio Mín." />
<input type="number" [(ngModel)]="maxPriceFilter" placeholder="Precio Máx." />
<p>Filtros Activos: {{ activeFilters() | json }}</p>
</div>
`,
standalone: true // Asumiendo componentes standalone
})
export class ProductFilterComponent {
categoryFilter = signal<string>('');
minPriceFilter = signal<number>(0);
maxPriceFilter = signal<number>(1000);
activeFilters = computed<FilterOptions>(() => ({
category: this.categoryFilter(),
minPrice: this.minPriceFilter(),
maxPrice: this.maxPriceFilter()
}));
constructor() {
// Cualquier lógica basada en el cambio de filtros se puede hacer con un effect
// effect(() => {
// console.log('Filtros cambiados:', this.activeFilters());
// // Aquí se podría emitir un evento o llamar a un servicio de búsqueda
// });
}
}
Aquí, categoryFilter, minPriceFilter, y maxPriceFilter son Signals que manejan el estado local de los inputs. activeFilters es un Signal computado que deriva el objeto de filtros completo de manera reactiva.
Conectando Signals a Selectores de NgRx
Esta es una de las integraciones más potentes. Podemos tomar un selector de NgRx (que devuelve un Observable) y convertirlo en un Signal usando la función toSignal() de @angular/core/rxjs-interop (o un operador similar de librerías complementarias).
Esto permite a los componentes "escuchar" el estado global de NgRx con la eficiencia y la simplicidad de los Signals, sin necesidad de suscripciones manuales o pipe async.
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { selectAllProducts, selectCartTotalItems } from '../state/product.selectors';
import { ProductState } from '../state/product.reducer';
import * as ProductActions from '../state/product.actions';
@Component({
selector: 'app-product-list',
template: `
<h2>Nuestros Productos</h2>
<p>Total de ítems en carrito: {{ cartTotalItems() }}</p>
<div *ngIf="products(); else loadingState">
<div *ngFor="let product of products()">
<h3>{{ product.name }}</h3>
<p>Precio: {{ product.price }}€</p>
<button (click)="addToCart(product.id)">Añadir al Carrito</button>
</div>
</div>
<ng-template #loadingState><p>Cargando productos...</p></ng-template>
`,
standalone: true
})
export class ProductListComponent implements OnInit {
products = toSignal(this.store.select(selectAllProducts), { initialValue: [] });
cartTotalItems = toSignal(this.store.select(selectCartTotalItems), { initialValue: 0 });
constructor(private store: Store<ProductState>) {}
ngOnInit(): void {
this.store.dispatch(ProductActions.loadProducts());
}
addToCart(productId: number): void {
this.store.dispatch(ProductActions.addProductToCart({ productId, quantity: 1 }));
}
}
Aquí, products y cartTotalItems son Signals que se derivan de los selectores de NgRx. Cualquier cambio en el estado de NgRx que afecte a estos selectores automáticamente actualizará los Signals y, por ende, el template del componente de manera eficiente.
Disparando Acciones de NgRx desde Signals
Los Signals también pueden ser la fuente de eventos que disparan acciones de NgRx. Utilizando effect(), podemos reaccionar a los cambios de un Signal y, en respuesta, despachar una acción de NgRx.
Imagina un Signal que representa el término de búsqueda de un usuario. Cada vez que este Signal cambia, queremos cargar nuevos resultados de productos a través de NgRx.
import { Component, signal, effect } from '@angular/core';
import { Store } from '@ngrx/store';
import { ProductState } from '../state/product.reducer';
import * as ProductActions from '../state/product.actions';
import { debounceTime } from 'rxjs/operators';
import { toObservable } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-search-component',
template: `
<input type="text" [(ngModel)]="searchTerm" placeholder="Buscar productos..." />
<p>Buscando: {{ searchTerm() }}</p>
`,
standalone: true
})
export class SearchComponent {
searchTerm = signal('');
constructor(private store: Store<ProductState>) {
effect(() => {
// Convertir el signal a observable para aplicar debounceTime
toObservable(this.searchTerm).pipe(
debounceTime(300)
).subscribe(term => {
if (term.length > 2) {
console.log('Dispatching search action for:', term);
// Aquí se despacharía una acción NgRx para buscar productos
// this.store.dispatch(ProductActions.searchProducts({ query: term }));
}
});
}, { allowSignalWrites: true }); // allowSignalWrites si el effect también actualiza signals
}
}
En este ejemplo, el effect reacciona a los cambios en searchTerm. Lo convertimos a un Observable temporalmente para usar debounceTime, una práctica común para evitar llamadas API excesivas. Luego, se despacha una acción NgRx con el término de búsqueda.
Estrategias de Migración Gradual
Para aplicaciones existentes que dependen fuertemente de RxJS y NgRx, la migración completa a Signals podría ser un esfuerzo considerable. El camino híbrido permite una migración gradual:
- Identifica el estado local: Comienza refactorizando el estado local de componentes que actualmente usan
BehaviorSubjecten servicios a Signals. - Aprovecha
toSignal(): En componentes, reemplaza las suscripciones a selectores de NgRx contoSignal(). - Nuevos módulos/características: Implementa nuevas funcionalidades usando Signals para el estado interno y NgRx para cualquier interacción con el estado global existente.
- Refactorización por etapas: A medida que te familiarices, considera refactorizar pequeñas porciones del estado global de NgRx a Signals si determinan que ya no cumplen los criterios de "estado global complejo" y pueden simplificarse.
Consideraciones Avanzadas y Mejores Prácticas
Rendimiento y Zonas de Detección de Cambios
La combinación de Signals con un enfoque sin Zone.js (o con ChangeDetectionStrategy.OnPush) maximiza el rendimiento. Signals permite a Angular ser extremadamente preciso sobre qué partes del DOM necesitan actualizarse. Al usar toSignal() para conectar el Store de NgRx a los componentes, minimizas la cantidad de Observables que el sistema de detección de cambios tradicional de Zone.js debe rastrear. Esto reduce los ciclos de detección de cambios y mejora la capacidad de respuesta de la aplicación.
Es recomendable que, si la mayor parte de la reactividad de tu aplicación se basa en Signals, explores la posibilidad de ejecutar tu aplicación Angular sin Zone.js o, al menos, asegurarte de que todos tus componentes utilicen ChangeDetectionStrategy.OnPush.
Pruebas Unitarias y de Integración en el Modelo Híbrido
Probar el estado local basado en Signals es directo: puedes manipular los Signals directamente en tus pruebas y verificar los valores resultantes de los Signals computados o el comportamiento de los efectos.
import { TestBed } from '@angular/core/testing';
import { ProductFilterComponent } from './product-filter.component';
describe('ProductFilterComponent', () => {
let component: ProductFilterComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProductFilterComponent] // Importar el componente standalone
});
const fixture = TestBed.createComponent(ProductFilterComponent);
component = fixture.componentInstance;
});
it('should update activeFilters when categoryFilter changes', () => {
component.categoryFilter.set('electronics');
expect(component.activeFilters().category).toBe('electronics');
});
it('should compute default activeFilters correctly', () => {
expect(component.activeFilters()).toEqual({
category: '',
minPrice: 0,
maxPrice: 1000
});
});
});
Para NgRx, las pruebas de reducers, selectores y effects permanecen inalteradas, ya que son funciones puras o utilizan el TestScheduler de RxJS. La integración con Signals (vía toSignal()) es una capa de presentación, y el comportamiento subyacente del Store de NgRx es lo que se debe probar de forma rigurosa.
Manejo de Efectos Secundarios y Asincronía
Mientras que NgRx Effects es la solución predilecta para la orquestación de efectos secundarios globales y complejos (como llamadas a API y lógica de negocio cruzada), Signals ofrece effect() para manejar efectos secundarios a nivel de componente o servicio local. Es crucial distinguir cuándo usar uno u otro:
effect()de Signals: Ideal para efectos secundarios que son puramente locales, como la sincronización con el DOM, el registro de consola, la manipulación de una librería de terceros o la actualización de otros Signals locales en respuesta a un cambio.- NgRx Effects: Preferible para efectos secundarios que interactúan con APIs externas, coordinan múltiples acciones, manejan errores globales o necesitan acceso a servicios inyectados de forma centralizada. Mantienen la lógica de negocio lejos de los componentes y facilitan las pruebas.
Recuerda que los effect() de Signals se ejecutan inmediatamente y son reactivos a los Signals que leen. Para la asincronía que necesita cancelación o estrategias más avanzadas (ej. debounceTime, switchMap), a menudo necesitarás convertir el Signal a un Observable temporalmente (usando toObservable) y luego despachar una acción de NgRx si el resultado afecta el estado global.
El Futuro de la Gestión de Estado en Angular
El "camino híbrido" no es solo una estrategia de transición; es una estrategia de diseño a largo plazo. A medida que Angular continúa evolucionando, la integración entre sus características nativas y librerías externas se volverá más fluida. Los desarrolladores se centrarán cada vez más en:
- Modularidad: Encapsular la lógica de estado en módulos o componentes autónomos, utilizando Signals donde sea apropiado.
- Componibilidad: Construir aplicaciones a partir de pequeños bloques reactivos que pueden combinarse fácilmente.
- Rendimiento por defecto: Aprovechar las optimizaciones que Signals y un enfoque sin Zone.js ofrecen de forma nativa.
- Experiencia de desarrollador (DX): Simplificar el código donde sea posible, pero manteniendo la robustez donde sea necesaria.
El uso estratégico de NgRx y Signals no solo mejorará la calidad de tus aplicaciones, sino que también hará que tu base de código sea más adaptable a los cambios futuros y más agradable de trabajar.
Conclusión
La gestión de estado en Angular ha evolucionado significativamente, ofreciendo a los desarrolladores herramientas más potentes y flexibles que nunca. En 2026, la elección no es entre Signals o NgRx, sino cómo combinarlos inteligentemente para maximizar la eficiencia y la escalabilidad de tus aplicaciones.
Adoptar una estrategia híbrida te permitirá beneficiarte de la reactividad granular y el rendimiento de Signals para el estado local y derivado, mientras conservas la robustez, previsibilidad y depurabilidad de NgRx para el estado global y los efectos secundarios complejos. Al aplicar estas técnicas, estarás construyendo aplicaciones Angular preparadas para el futuro, que son más rápidas, más fáciles de mantener y un placer desarrollar. ¡Es hora de llevar tus habilidades de gestión de estado al siguiente nivel!