Servicios de estado con WritableSignal
Los servicios de estado representan una alternativa moderna y simplificada al manejo tradicional con RxJS para gestionar el estado global de nuestras aplicaciones Angular. Con la estabilización completa de signals en Angular 20, podemos crear servicios que aprovechan WritableSignal
como la fuente de verdad única para nuestro estado.
Arquitectura básica de un servicio de estado
Un servicio de estado con signals sigue un patrón directo donde el estado se almacena en signals privados y se expone mediante signals de solo lectura, junto con métodos públicos para las actualizaciones:
import { Injectable, signal, Signal } from '@angular/core';
interface UserState {
id: string | null;
name: string;
email: string;
isAuthenticated: boolean;
}
@Injectable({
providedIn: 'root'
})
export class UserStateService {
// Estado privado como WritableSignal
private readonly _userState = signal<UserState>({
id: null,
name: '',
email: '',
isAuthenticated: false
});
// Exposición pública como Signal readonly
public readonly userState: Signal<UserState> = this._userState.asReadonly();
// Métodos públicos para actualizar estado
login(user: { id: string; name: string; email: string }) {
this._userState.set({
...user,
isAuthenticated: true
});
}
logout() {
this._userState.set({
id: null,
name: '',
email: '',
isAuthenticated: false
});
}
updateProfile(name: string, email: string) {
this._userState.update(current => ({
...current,
name,
email
}));
}
}
Patterns de actualización inmutable
El manejo del estado con signals requiere actualizaciones inmutables para garantizar que las notificaciones de cambio se disparen correctamente. Utilizamos principalmente dos métodos:
Actualización completa con **set()**
:
@Injectable({
providedIn: 'root'
})
export class ShoppingCartService {
private readonly _cart = signal<CartItem[]>([]);
public readonly cart = this._cart.asReadonly();
public readonly itemCount = signal(0);
// Reemplazar completamente el estado
loadCart(items: CartItem[]) {
this._cart.set([...items]);
this.itemCount.set(items.reduce((sum, item) => sum + item.quantity, 0));
}
clearCart() {
this._cart.set([]);
this.itemCount.set(0);
}
}
Actualización parcial con **update()**
:
// Agregar item al carrito
addItem(product: Product) {
this._cart.update(currentCart => {
const existingItem = currentCart.find(item => item.productId === product.id);
if (existingItem) {
// Actualizar cantidad si ya existe
return currentCart.map(item =>
item.productId === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// Agregar nuevo item
return [...currentCart, {
productId: product.id,
name: product.name,
price: product.price,
quantity: 1
}];
}
});
// Actualizar contador
this.itemCount.update(count => count + 1);
}
// Remover item específico
removeItem(productId: string) {
this._cart.update(currentCart =>
currentCart.filter(item => item.productId !== productId)
);
this.itemCount.update(count =>
count - (this._cart().find(item => item.productId === productId)?.quantity || 0)
);
}
Servicios de estado complejos
Para estados más elaborados, podemos estructurar servicios que manejen múltiples aspectos del estado de la aplicación:
interface AppThemeState {
mode: 'light' | 'dark';
primaryColor: string;
fontSize: 'small' | 'medium' | 'large';
reducedMotion: boolean;
}
@Injectable({
providedIn: 'root'
})
export class ThemeStateService {
private readonly _themeState = signal<AppThemeState>({
mode: 'light',
primaryColor: '#2196f3',
fontSize: 'medium',
reducedMotion: false
});
public readonly themeState = this._themeState.asReadonly();
// Actualización de modo específico
toggleMode() {
this._themeState.update(current => ({
...current,
mode: current.mode === 'light' ? 'dark' : 'light'
}));
}
// Actualización de múltiples propiedades
updateThemeSettings(settings: Partial<AppThemeState>) {
this._themeState.update(current => ({
...current,
...settings
}));
}
// Restaurar configuración por defecto
resetToDefaults() {
this._themeState.set({
mode: 'light',
primaryColor: '#2196f3',
fontSize: 'medium',
reducedMotion: false
});
}
}
Integración con efectos para side effects
Los servicios de estado con signals se integran naturalmente con effects para manejar side effects como persistencia o sincronización:
import { Injectable, signal, effect } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UserPreferencesService {
private readonly _preferences = signal({
language: 'es',
notifications: true,
autoSave: true
});
public readonly preferences = this._preferences.asReadonly();
constructor() {
// Cargar preferencias desde localStorage al inicializar
this.loadFromStorage();
// Effect para persistir automáticamente los cambios
effect(() => {
const currentPreferences = this._preferences();
localStorage.setItem('userPreferences', JSON.stringify(currentPreferences));
});
}
private loadFromStorage() {
const stored = localStorage.getItem('userPreferences');
if (stored) {
try {
const preferences = JSON.parse(stored);
this._preferences.set(preferences);
} catch (error) {
console.warn('Error loading preferences from storage:', error);
}
}
}
updateLanguage(language: string) {
this._preferences.update(current => ({
...current,
language
}));
}
toggleNotifications() {
this._preferences.update(current => ({
...current,
notifications: !current.notifications
}));
}
}
Esta aproximación con WritableSignal proporciona una API limpia y reactiva que simplifica significativamente el manejo de estado comparado con los patterns tradicionales de RxJS, manteniendo la reactividad automática que caracteriza al ecosistema de signals de Angular.
Patterns de estado reactivo y computed values
Los computed signals representan el mecanismo ideal para crear estado derivado en nuestros servicios de estado, permitiendo que los valores calculados se actualicen automáticamente cuando cambian sus dependencias. Esta aproximación reactiva elimina la necesidad de sincronizar manualmente el estado derivado.
Estado derivado con computed signals
Un patrón fundamental consiste en exponer computed signals junto al estado base para proporcionar vistas calculadas que se mantienen siempre sincronizadas:
import { Injectable, signal, computed } from '@angular/core';
interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
}
@Injectable({
providedIn: 'root'
})
export class ShoppingCartService {
private readonly _items = signal<CartItem[]>([]);
// Estado base expuesto como readonly
public readonly items = this._items.asReadonly();
// Estado derivado con computed signals
public readonly totalItems = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
public readonly totalPrice = computed(() =>
this._items().reduce((sum, item) => sum + (item.price * item.quantity), 0)
);
public readonly isEmpty = computed(() => this._items().length === 0);
public readonly averageItemPrice = computed(() => {
const items = this._items();
if (items.length === 0) return 0;
return this.totalPrice() / this.totalItems();
});
addItem(item: CartItem) {
this._items.update(current => [...current, item]);
}
removeItem(productId: string) {
this._items.update(current =>
current.filter(item => item.productId !== productId)
);
}
}
Computed signals con múltiples dependencias
Los computed signals pueden depender de múltiples fuentes de estado, creando derivaciones complejas que se recalculan eficientemente solo cuando es necesario:
@Injectable({
providedIn: 'root'
})
export class UserDashboardService {
private readonly _user = signal({
id: '',
name: '',
email: '',
role: 'user' as 'admin' | 'user' | 'moderator'
});
private readonly _permissions = signal<string[]>([]);
private readonly _notifications = signal<Notification[]>([]);
public readonly user = this._user.asReadonly();
public readonly permissions = this._permissions.asReadonly();
public readonly notifications = this._notifications.asReadonly();
// Computed que combina múltiples signals
public readonly canManageUsers = computed(() => {
const user = this._user();
const perms = this._permissions();
return user.role === 'admin' || perms.includes('manage_users');
});
public readonly unreadNotifications = computed(() =>
this._notifications().filter(n => !n.isRead)
);
public readonly dashboardSummary = computed(() => ({
userName: this._user().name,
isAdmin: this._user().role === 'admin',
unreadCount: this.unreadNotifications().length,
canModerate: this.canManageUsers(),
totalNotifications: this._notifications().length
}));
updateUser(userData: Partial<typeof this._user>) {
this._user.update(current => ({ ...current, ...userData }));
}
setPermissions(permissions: string[]) {
this._permissions.set([...permissions]);
}
}
Patterns de filtrado y búsqueda reactivos
Un caso de uso frecuente involucra la creación de listas filtradas que reaccionen automáticamente a cambios en los criterios de búsqueda:
interface Product {
id: string;
name: string;
category: string;
price: number;
inStock: boolean;
}
@Injectable({
providedIn: 'root'
})
export class ProductCatalogService {
private readonly _products = signal<Product[]>([]);
private readonly _searchTerm = signal('');
private readonly _selectedCategory = signal<string | null>(null);
private readonly _showOnlyInStock = signal(false);
public readonly products = this._products.asReadonly();
public readonly searchTerm = this._searchTerm.asReadonly();
public readonly selectedCategory = this._selectedCategory.asReadonly();
public readonly showOnlyInStock = this._showOnlyInStock.asReadonly();
// Lista filtrada reactiva
public readonly filteredProducts = computed(() => {
const products = this._products();
const term = this._searchTerm().toLowerCase();
const category = this._selectedCategory();
const onlyInStock = this._showOnlyInStock();
return products.filter(product => {
const matchesSearch = !term ||
product.name.toLowerCase().includes(term);
const matchesCategory = !category ||
product.category === category;
const matchesStock = !onlyInStock || product.inStock;
return matchesSearch && matchesCategory && matchesStock;
});
});
// Estadísticas derivadas
public readonly catalogStats = computed(() => {
const all = this._products();
const filtered = this.filteredProducts();
return {
totalProducts: all.length,
filteredCount: filtered.length,
inStockCount: all.filter(p => p.inStock).length,
categories: [...new Set(all.map(p => p.category))],
averagePrice: all.reduce((sum, p) => sum + p.price, 0) / all.length || 0
};
});
// Métodos para actualizar filtros
updateSearchTerm(term: string) {
this._searchTerm.set(term);
}
selectCategory(category: string | null) {
this._selectedCategory.set(category);
}
toggleStockFilter() {
this._showOnlyInStock.update(current => !current);
}
clearFilters() {
this._searchTerm.set('');
this._selectedCategory.set(null);
this._showOnlyInStock.set(false);
}
}
Computed signals para validación de estado
Los computed signals resultan especialmente útiles para implementar validación reactiva del estado de la aplicación:
interface FormData {
username: string;
email: string;
password: string;
confirmPassword: string;
}
@Injectable({
providedIn: 'root'
})
export class RegistrationFormService {
private readonly _formData = signal<FormData>({
username: '',
email: '',
password: '',
confirmPassword: ''
});
public readonly formData = this._formData.asReadonly();
// Validaciones individuales como computed
public readonly isUsernameValid = computed(() => {
const username = this._formData().username;
return username.length >= 3 && /^[a-zA-Z0-9_]+$/.test(username);
});
public readonly isEmailValid = computed(() => {
const email = this._formData().email;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
});
public readonly isPasswordValid = computed(() => {
const password = this._formData().password;
return password.length >= 8 && /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password);
});
public readonly passwordsMatch = computed(() => {
const data = this._formData();
return data.password === data.confirmPassword && data.password.length > 0;
});
// Validación global del formulario
public readonly isFormValid = computed(() =>
this.isUsernameValid() &&
this.isEmailValid() &&
this.isPasswordValid() &&
this.passwordsMatch()
);
// Objeto con todos los errores
public readonly validationErrors = computed(() => ({
username: !this.isUsernameValid() ? 'Username must be at least 3 characters and contain only letters, numbers, and underscores' : null,
email: !this.isEmailValid() ? 'Please enter a valid email address' : null,
password: !this.isPasswordValid() ? 'Password must be at least 8 characters with uppercase, lowercase, and number' : null,
confirmPassword: !this.passwordsMatch() ? 'Passwords do not match' : null
}));
updateField(field: keyof FormData, value: string) {
this._formData.update(current => ({
...current,
[field]: value
}));
}
resetForm() {
this._formData.set({
username: '',
email: '',
password: '',
confirmPassword: ''
});
}
}
Optimización con computed signals
Los computed signals implementan automáticamente memoización, recalculándose únicamente cuando sus dependencias cambian. Esto los convierte en una herramienta ideal para cálculos costosos que deben mantenerse sincronizados:
@Injectable({
providedIn: 'root'
})
export class AnalyticsService {
private readonly _salesData = signal<SaleRecord[]>([]);
private readonly _dateRange = signal({
start: new Date(),
end: new Date()
});
public readonly salesData = this._salesData.asReadonly();
public readonly dateRange = this._dateRange.asReadonly();
// Cálculo costoso memoizado automáticamente
public readonly salesAnalytics = computed(() => {
const sales = this._salesData();
const { start, end } = this._dateRange();
const filteredSales = sales.filter(sale => {
const saleDate = new Date(sale.date);
return saleDate >= start && saleDate <= end;
});
// Cálculos complejos que solo se ejecutan cuando cambian las dependencias
const totalRevenue = filteredSales.reduce((sum, sale) => sum + sale.amount, 0);
const averageSale = filteredSales.length > 0 ? totalRevenue / filteredSales.length : 0;
const topProducts = this.calculateTopProducts(filteredSales);
return {
totalSales: filteredSales.length,
totalRevenue,
averageSale,
topProducts,
period: `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`
};
});
private calculateTopProducts(sales: SaleRecord[]) {
const productSales = new Map<string, number>();
sales.forEach(sale => {
sale.items.forEach(item => {
const current = productSales.get(item.productId) || 0;
productSales.set(item.productId, current + item.quantity);
});
});
return Array.from(productSales.entries())
.sort(([,a], [,b]) => b - a)
.slice(0, 5);
}
updateDateRange(start: Date, end: Date) {
this._dateRange.set({ start, end });
}
}
Esta aproximación con computed signals proporciona un sistema reactivo altamente eficiente donde el estado derivado se mantiene automáticamente sincronizado, eliminando la complejidad de gestión manual típica en otros sistemas de estado.
Alternativa NgRx
Para casos empresariales más avanzados, donde las aplicaciones Angular se vuelven más complejas y más extensas, podría ser recomendable optar por alternativas complementarias como NgRx Signals.

Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Angular
Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender cómo utilizar WritableSignal para gestionar estado global en Angular.
- Aprender a implementar servicios de estado con actualizaciones inmutables.
- Conocer el uso de computed signals para crear estado derivado y reactivo.
- Aplicar patterns de filtrado, búsqueda y validación reactiva con signals.
- Integrar efectos para manejar side effects y persistencia de estado.