Contactar

Señales en Angular 20: Guía Completa

Señales en Angular 20: Guía Completa

Señales en Angular 20: Guía Completa con Ejemplos de Producción

Introducción a las Señales en Angular

Las señales en Angular representan una de las innovaciones más significativas en el ecosistema del framework desde su lanzamiento inicial. Con el lanzamiento de Angular 20 en mayo de 2025, las señales se han convertido en un sistema de reactividad estable y robusto que transforma completamente la manera en que gestionamos el estado en nuestras aplicaciones Angular.

Las señales en Angular son primitivas reactivas que permiten rastrear de manera granular cómo y dónde se utiliza el estado en toda la aplicación. Esto permite al framework optimizar las actualizaciones de renderizado de una manera que anteriormente no era posible con Zone.js y la detección de cambios tradicional.

¿Qué son las Señales en Angular?

Una señal es un contenedor especial que envuelve un valor y notifica automáticamente a los consumidores interesados cuando ese valor cambia. A diferencia de las variables tradicionales de JavaScript, las señales en Angular proporcionan un mecanismo reactivo integrado que permite al framework saber exactamente qué partes de la interfaz de usuario necesitan actualizarse cuando cambia el estado.

Ventajas de las Señales en Angular sobre Variables Normales

Antes de profundizar en cada tipo de señal, es importante entender por qué deberíamos usar señales en Angular en lugar de variables normales:

1. Detección de Cambios Optimizada: Las señales en Angular permiten una detección de cambios quirúrgica. Solo se actualizan las partes de la UI que realmente dependen del valor que cambió, en lugar de verificar todo el árbol de componentes.

2. Reducción del Bundle Size: Con las señales en Angular y el modo zoneless, puedes eliminar Zone.js de tu aplicación, reduciendo el tamaño del bundle hasta en un 30%.

3. Rendimiento Mejorado: Las señales en Angular calculan valores derivados de manera perezosa (lazy) y cachean los resultados, evitando cálculos innecesarios.

4. Código Más Limpio: Las señales en Angular eliminan la necesidad de mucho código boilerplate asociado con RxJS para casos de uso simples de gestión de estado.

5. Debugging Simplificado: Las señales en Angular proporcionan trazas de error más claras y herramientas de depuración mejoradas en Angular DevTools.

Tipos de Señales en Angular 20

Angular 20 introduce varios tipos de señales, cada una diseñada para casos de uso específicos. Vamos a explorar cada tipo en detalle con ejemplos prácticos de producción.

1. WritableSignal: Las Señales Básicas en Angular

¿Qué es un WritableSignal?

El WritableSignal es el tipo de señal más fundamental en Angular. Es una señal que puede ser leída y modificada directamente. Este es el punto de partida para trabajar con señales en Angular.

Creación y Uso Básico

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div class="counter-container">
      <h2>Contador: {{ count() }}</h2>
      <button (click)="increment()">Incrementar</button>
      <button (click)="decrement()">Decrementar</button>
      <button (click)="reset()">Resetear</button>
    </div>
  `
})
export class CounterComponent {
  // Crear una señal writable con valor inicial 0
  count = signal(0);

  increment() {
    // Método 1: set() - establece un nuevo valor directamente
    this.count.set(this.count() + 1);
  }

  decrement() {
    // Método 2: update() - actualiza basándose en el valor anterior
    this.count.update(value => value - 1);
  }

  reset() {
    this.count.set(0);
  }
}

API de WritableSignal

Las señales en Angular de tipo writable proporcionan tres métodos principales:

1. Lectura: Llamar a la señal como función: count()

2. set(): Establece un nuevo valor directamente

count.set(10);

3. update(): Actualiza el valor basándose en el valor anterior

count.update(current => current + 1);

Ejemplo de Producción: Gestión de Carrito de Compras

import { Component, signal } from '@angular/core';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

@Component({
  selector: 'app-shopping-cart',
  template: `
    <div class="cart">
      <h2>Carrito de Compras</h2>
      <div class="cart-summary">
        <p>Artículos: {{ itemCount() }}</p>
        <p>Total: ${{ totalPrice() }}</p>
      </div>
      @for (item of cartItems(); track item.id) {
        <div class="cart-item">
          <span>{{ item.name }}</span>
          <span>Cantidad: {{ item.quantity }}</span>
          <span>${{ item.price * item.quantity }}</span>
          <button (click)="removeItem(item.id)">Eliminar</button>
        </div>
      }
      <button (click)="clearCart()">Vaciar Carrito</button>
    </div>
  `
})
export class ShoppingCartComponent {
  cartItems = signal<CartItem[]>([]);

  addItem(item: CartItem) {
    this.cartItems.update(items => {
      const existingItem = items.find(i => i.id === item.id);
      
      if (existingItem) {
        // Si el artículo ya existe, incrementar cantidad
        return items.map(i => 
          i.id === item.id 
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      
      // Si es nuevo, agregarlo al carrito
      return [...items, { ...item, quantity: 1 }];
    });
  }

  removeItem(itemId: string) {
    this.cartItems.update(items => 
      items.filter(item => item.id !== itemId)
    );
  }

  clearCart() {
    this.cartItems.set([]);
  }

  itemCount() {
    return this.cartItems().reduce((sum, item) => sum + item.quantity, 0);
  }

  totalPrice() {
    return this.cartItems().reduce(
      (sum, item) => sum + (item.price * item.quantity), 
      0
    );
  }
}

Cuándo Usar WritableSignal vs Variables Normales

Usa WritableSignal cuando:

  • El valor se utiliza en el template y necesita actualizar la UI automáticamente
  • Otros componentes o servicios necesitan reaccionar a cambios en el valor
  • Quieres aprovechar la detección de cambios optimizada de Angular
  • Necesitas compartir estado entre múltiples componentes

Usa variables normales cuando:

  • El valor es puramente interno y nunca afecta la UI
  • El valor se calcula en tiempo de ejecución y se descarta inmediatamente
  • Trabajas con datos temporales en funciones que no requieren reactividad

2. Computed Signals: Valores Derivados Reactivos

¿Qué son los Computed Signals?

Los computed signals son señales de solo lectura que derivan su valor automáticamente de otras señales. Son una de las características más poderosas de las señales en Angular, permitiendo crear relaciones reactivas complejas de manera declarativa.

Características Clave

1. Evaluación Perezosa: El computed signal solo calcula su valor cuando se lee por primera vez.

2. Caché Automático: El resultado se almacena en caché y se reutiliza hasta que cambie alguna dependencia.

3. Actualización Automática: Angular rastrea automáticamente todas las señales leídas durante el cálculo.

4. Solo Lectura: No puedes asignar valores directamente a un computed signal.

Ejemplo Básico

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-price-calculator',
  template: `
    <div class="calculator">
      <h3>Calculadora de Precio</h3>
      <input 
        type="number" 
        [value]="basePrice()" 
        (input)="updateBasePrice($event)"
        placeholder="Precio base"
      />
      <input 
        type="number" 
        [value]="taxRate()" 
        (input)="updateTaxRate($event)"
        placeholder="Tasa de impuesto (%)"
      />
      <input 
        type="number" 
        [value]="discount()" 
        (input)="updateDiscount($event)"
        placeholder="Descuento (%)"
      />
      
      <div class="results">
        <p>Subtotal: ${{ subtotal() }}</p>
        <p>Impuestos: ${{ taxAmount() }}</p>
        <p>Descuento: -${{ discountAmount() }}</p>
        <p><strong>Total Final: ${{ finalPrice() }}</strong></p>
      </div>
    </div>
  `
})
export class PriceCalculatorComponent {
  basePrice = signal(100);
  taxRate = signal(10); // porcentaje
  discount = signal(5); // porcentaje

  // Computed signals - se actualizan automáticamente
  subtotal = computed(() => this.basePrice());
  
  taxAmount = computed(() => {
    const subtotal = this.subtotal();
    const rate = this.taxRate();
    return (subtotal * rate) / 100;
  });
  
  discountAmount = computed(() => {
    const subtotal = this.subtotal();
    const discountRate = this.discount();
    return (subtotal * discountRate) / 100;
  });
  
  finalPrice = computed(() => {
    return this.subtotal() + this.taxAmount() - this.discountAmount();
  });

  updateBasePrice(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.basePrice.set(Number(value));
  }

  updateTaxRate(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.taxRate.set(Number(value));
  }

  updateDiscount(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.discount.set(Number(value));
  }
}

Ejemplo de Producción: Dashboard con Métricas Complejas

import { Component, signal, computed } from '@angular/core';

interface Sale {
  id: string;
  amount: number;
  date: Date;
  category: string;
  region: string;
}

@Component({
  selector: 'app-sales-dashboard',
  template: `
    <div class="dashboard">
      <h2>Dashboard de Ventas</h2>
      
      <div class="filters">
        <select (change)="setSelectedRegion($event)">
          <option value="">Todas las regiones</option>
          <option value="north">Norte</option>
          <option value="south">Sur</option>
          <option value="east">Este</option>
          <option value="west">Oeste</option>
        </select>
        
        <select (change)="setSelectedCategory($event)">
          <option value="">Todas las categorías</option>
          <option value="electronics">Electrónica</option>
          <option value="clothing">Ropa</option>
          <option value="food">Alimentos</option>
        </select>
      </div>
      
      <div class="metrics">
        <div class="metric-card">
          <h3>Ventas Totales</h3>
          <p class="metric-value">${{ totalSales() }}</p>
        </div>
        
        <div class="metric-card">
          <h3>Venta Promedio</h3>
          <p class="metric-value">${{ averageSale() }}</p>
        </div>
        
        <div class="metric-card">
          <h3>Número de Ventas</h3>
          <p class="metric-value">{{ filteredSales().length }}</p>
        </div>
        
        <div class="metric-card">
          <h3>Mejor Región</h3>
          <p class="metric-value">{{ topRegion() }}</p>
        </div>
      </div>
      
      <div class="sales-list">
        @for (sale of filteredSales(); track sale.id) {
          <div class="sale-item">
            <span>{{ sale.date | date }}</span>
            <span>{{ sale.category }}</span>
            <span>{{ sale.region }}</span>
            <span>${{ sale.amount }}</span>
          </div>
        }
      </div>
    </div>
  `
})
export class SalesDashboardComponent {
  // Estado base
  allSales = signal<Sale[]>([
    { id: '1', amount: 150, date: new Date(), category: 'electronics', region: 'north' },
    { id: '2', amount: 200, date: new Date(), category: 'clothing', region: 'south' },
    { id: '3', amount: 75, date: new Date(), category: 'food', region: 'north' },
    // ... más ventas
  ]);
  
  selectedRegion = signal<string>('');
  selectedCategory = signal<string>('');

  // Computed signals para filtrado y cálculos
  filteredSales = computed(() => {
    let sales = this.allSales();
    const region = this.selectedRegion();
    const category = this.selectedCategory();
    
    if (region) {
      sales = sales.filter(sale => sale.region === region);
    }
    
    if (category) {
      sales = sales.filter(sale => sale.category === category);
    }
    
    return sales;
  });

  totalSales = computed(() => {
    return this.filteredSales().reduce((sum, sale) => sum + sale.amount, 0);
  });

  averageSale = computed(() => {
    const filtered = this.filteredSales();
    if (filtered.length === 0) return 0;
    return this.totalSales() / filtered.length;
  });

  topRegion = computed(() => {
    const salesByRegion = this.filteredSales().reduce((acc, sale) => {
      acc[sale.region] = (acc[sale.region] || 0) + sale.amount;
      return acc;
    }, {} as Record<string, number>);
    
    const entries = Object.entries(salesByRegion);
    if (entries.length === 0) return 'N/A';
    
    return entries.reduce((max, [region, total]) => 
      total > max.total ? { region, total } : max,
      { region: '', total: 0 }
    ).region;
  });

  setSelectedRegion(event: Event) {
    const value = (event.target as HTMLSelectElement).value;
    this.selectedRegion.set(value);
  }

  setSelectedCategory(event: Event) {
    const value = (event.target as HTMLSelectElement).value;
    this.selectedCategory.set(value);
  }
}

Rastreo Condicional en Computed Signals

Una característica avanzada de las señales en Angular es el rastreo condicional:

const showDetails = signal(false);
const userCount = signal(100);

const displayText = computed(() => {
  if (showDetails()) {
    return `Hay ${userCount()} usuarios activos`;
  } else {
    return 'Información oculta';
  }
});

En este ejemplo, si showDetails es false, cambios en userCount NO causarán que displayText se recalcule. Esto es porque Angular solo rastrea las señales que fueron realmente leídas durante la ejecución del computed.

Cuándo Usar Computed Signals

Usa Computed cuando:

  • Necesitas derivar valores de otras señales automáticamente
  • Quieres realizar cálculos costosos solo cuando cambien las dependencias
  • El valor es de solo lectura y no debe ser modificado directamente
  • Necesitas transformar o filtrar datos reactivamente

No uses Computed cuando:

  • Necesitas modificar el valor resultante
  • El cálculo no depende de ninguna señal
  • Estás realizando efectos secundarios (usa effect en su lugar)

3. Effect: Efectos Secundarios Reactivos

¿Qué es el Effect en las Señales de Angular?

El effect es una función especial en las señales de Angular que ejecuta código cuando cambian las señales que lee. A diferencia de computed, effect se usa específicamente para efectos secundarios como logging, sincronización con APIs externas, o localStorage.

Características del Effect

1. Ejecución Automática: Se ejecuta automáticamente cuando cualquier señal leída cambia.

2. Contexto de Inyección: Debe ejecutarse dentro de un contexto de inyección (constructor, initializer).

3. Efectos Secundarios: Diseñado específicamente para operaciones con side effects.

4. Limpieza Automática: Angular limpia automáticamente los effects cuando se destruye el componente.

Ejemplo Básico de Effect

import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-logger',
  template: `
    <div>
      <input 
        [value]="username()" 
        (input)="updateUsername($event)"
        placeholder="Nombre de usuario"
      />
      <p>Caracteres: {{ username().length }}</p>
    </div>
  `
})
export class LoggerComponent {
  username = signal('');

  constructor() {
    // Effect se ejecuta automáticamente cuando username cambia
    effect(() => {
      const name = this.username();
      console.log(`Username cambió a: ${name}`);
      
      // Guardar en localStorage (efecto secundario)
      localStorage.setItem('username', name);
    });
  }

  updateUsername(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.username.set(value);
  }
}

Ejemplo de Producción: Sincronización con API

import { Component, signal, effect, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: boolean;
  language: string;
}

@Component({
  selector: 'app-user-settings',
  template: `
    <div class="settings">
      <h2>Configuración de Usuario</h2>
      
      <div class="setting-group">
        <label>
          Tema:
          <select [value]="preferences().theme" (change)="updateTheme($event)">
            <option value="light">Claro</option>
            <option value="dark">Oscuro</option>
          </select>
        </label>
      </div>
      
      <div class="setting-group">
        <label>
          <input 
            type="checkbox" 
            [checked]="preferences().notifications"
            (change)="toggleNotifications()"
          />
          Notificaciones Activadas
        </label>
      </div>
      
      <div class="setting-group">
        <label>
          Idioma:
          <select [value]="preferences().language" (change)="updateLanguage($event)">
            <option value="es">Español</option>
            <option value="en">English</option>
            <option value="fr">Français</option>
          </select>
        </label>
      </div>
      
      @if (isSaving()) {
        <p class="status">Guardando cambios...</p>
      }
      
      @if (lastSaved()) {
        <p class="status">Última actualización: {{ lastSaved() }}</p>
      }
    </div>
  `
})
export class UserSettingsComponent {
  private http = inject(HttpClient);
  
  preferences = signal<UserPreferences>({
    theme: 'light',
    notifications: true,
    language: 'es'
  });
  
  isSaving = signal(false);
  lastSaved = signal<string | null>(null);

  constructor() {
    // Effect para guardar automáticamente en el servidor
    effect(() => {
      const prefs = this.preferences();
      
      // Skip de la primera ejecución (carga inicial)
      if (this.lastSaved() !== null) {
        this.saveToServer(prefs);
      }
    });
    
    // Effect para aplicar el tema inmediatamente
    effect(() => {
      const theme = this.preferences().theme;
      document.body.setAttribute('data-theme', theme);
    });
    
    // Effect para logging y analytics
    effect(() => {
      const prefs = this.preferences();
      console.log('Preferencias actualizadas:', prefs);
      
      // Enviar evento a analytics
      this.trackAnalytics('preferences_changed', prefs);
    });
  }

  private async saveToServer(preferences: UserPreferences) {
    this.isSaving.set(true);
    
    try {
      await this.http.put('/api/user/preferences', preferences)
        .toPromise();
      
      const now = new Date().toLocaleTimeString();
      this.lastSaved.set(now);
    } catch (error) {
      console.error('Error guardando preferencias:', error);
    } finally {
      this.isSaving.set(false);
    }
  }

  private trackAnalytics(event: string, data: any) {
    // Simulación de tracking
    if (typeof window !== 'undefined' && (window as any).gtag) {
      (window as any).gtag('event', event, data);
    }
  }

  updateTheme(event: Event) {
    const theme = (event.target as HTMLSelectElement).value as 'light' | 'dark';
    this.preferences.update(prefs => ({ ...prefs, theme }));
  }

  toggleNotifications() {
    this.preferences.update(prefs => ({
      ...prefs,
      notifications: !prefs.notifications
    }));
  }

  updateLanguage(event: Event) {
    const language = (event.target as HTMLSelectElement).value;
    this.preferences.update(prefs => ({ ...prefs, language }));
  }
}

Effect con Limpieza Manual

import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-realtime-monitor',
  template: `
    <div>
      <h3>Monitor en Tiempo Real</h3>
      <button (click)="toggleMonitoring()">
        {{ isMonitoring() ? 'Detener' : 'Iniciar' }} Monitoreo
      </button>
      <p>Estado: {{ status() }}</p>
    </div>
  `
})
export class RealtimeMonitorComponent {
  isMonitoring = signal(false);
  status = signal('Inactivo');

  constructor() {
    effect((onCleanup) => {
      if (this.isMonitoring()) {
        // Configurar WebSocket o polling
        const intervalId = setInterval(() => {
          this.status.set(`Activo - ${new Date().toLocaleTimeString()}`);
        }, 1000);
        
        // Función de limpieza - se ejecuta cuando el effect se re-ejecuta
        // o cuando el componente se destruye
        onCleanup(() => {
          clearInterval(intervalId);
          this.status.set('Detenido');
        });
      }
    });
  }

  toggleMonitoring() {
    this.isMonitoring.update(current => !current);
  }
}

Cuándo Usar Effect

Usa Effect cuando:

  • Necesitas realizar efectos secundarios (API calls, logging, analytics)
  • Quieres sincronizar señales con sistemas externos (localStorage, WebSockets)
  • Necesitas ejecutar código cuando cambien múltiples señales
  • Requieres limpieza de recursos (intervalos, subscripciones)

No uses Effect cuando:

  • Solo necesitas derivar un valor (usa computed)
  • Estás manejando eventos de usuario (usa event handlers)
  • El efecto no depende de ninguna señal

4. LinkedSignal: El Eslabón Perdido en la Reactividad

¿Qué es LinkedSignal?

El linkedSignal es una de las incorporaciones más interesantes de Angular 19, estabilizada en Angular 20. Resuelve un problema específico: crear señales que son tanto reactivas (dependen de otras señales) como escribibles (pueden ser modificadas manualmente).

El Problema que Resuelve

Antes de linkedSignal, tenías que elegir:

  • Computed signals: Reactivos pero de solo lectura
  • WritableSignals: Escribibles pero sin reactividad automática

LinkedSignal combina ambos mundos: es reactivo Y escribible.

Sintaxis y Uso Básico

import { Component, signal, linkedSignal } from '@angular/core';

@Component({
  selector: 'app-product-filter',
  template: `
    <div>
      <h3>Categoría seleccionada: {{ selectedCategory().name }}</h3>
      <p>Productos en esta categoría: {{ selectedCategory().productCount }}</p>
      
      @for (category of categories(); track category.id) {
        <button (click)="selectCategory(category)">
          {{ category.name }}
        </button>
      }
      
      <button (click)="resetSelection()">Restablecer</button>
    </div>
  `
})
export class ProductFilterComponent {
  categories = signal([
    { id: '1', name: 'Electrónica', productCount: 45 },
    { id: '2', name: 'Ropa', productCount: 120 },
    { id: '3', name: 'Hogar', productCount: 78 },
  ]);

  // LinkedSignal: se inicializa automáticamente con la primera categoría
  // pero puede ser cambiado manualmente
  selectedCategory = linkedSignal(() => this.categories()[0]);

  selectCategory(category: any) {
    // Podemos cambiar el valor manualmente
    this.selectedCategory.set(category);
  }

  resetSelection() {
    // Si cambiamos categories, selectedCategory se reseteará automáticamente
    this.categories.update(cats => [...cats]);
  }
}

Cómo Funciona LinkedSignal

LinkedSignal tiene un comportamiento único:

  1. Inicialización Reactiva: Se calcula inicialmente basándose en la función de computación
  2. Escritura Manual: Puedes modificarlo con .set() o .update()
  3. Reset Automático: Si la señal fuente cambia, se recalcula automáticamente

Ejemplo de Producción: Formulario con Datos del Servidor

import { Component, inject, linkedSignal, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
}

@Component({
  selector: 'app-product-editor',
  template: `
    <div class="editor">
      <h2>Editor de Productos</h2>
      
      <div class="product-selector">
        <label>Seleccionar Producto:</label>
        <select (change)="loadProduct($event)">
          <option value="">-- Nuevo Producto --</option>
          @for (product of availableProducts(); track product.id) {
            <option [value]="product.id">{{ product.name }}</option>
          }
        </select>
      </div>
      
      <form class="product-form">
        <div class="form-group">
          <label>Nombre:</label>
          <input 
            type="text"
            [value]="editableProduct().name"
            (input)="updateField('name', $event)"
          />
        </div>
        
        <div class="form-group">
          <label>Precio:</label>
          <input 
            type="number"
            [value]="editableProduct().price"
            (input)="updateField('price', $event)"
          />
        </div>
        
        <div class="form-group">
          <label>Stock:</label>
          <input 
            type="number"
            [value]="editableProduct().stock"
            (input)="updateField('stock', $event)"
          />
        </div>
        
        <div class="form-group">
          <label>Categoría:</label>
          <input 
            type="text"
            [value]="editableProduct().category"
            (input)="updateField('category', $event)"
          />
        </div>
        
        <div class="form-actions">
          <button type="button" (click)="saveProduct()">Guardar</button>
          <button type="button" (click)="resetForm()">Resetear</button>
        </div>
      </form>
      
      @if (hasChanges()) {
        <div class="warning">
          ⚠️ Hay cambios sin guardar
        </div>
      }
    </div>
  `
})
export class ProductEditorComponent {
  private http = inject(HttpClient);
  
  // Datos del servidor
  private serverProducts$ = this.http.get<Product[]>('/api/products');
  availableProducts = toSignal(this.serverProducts$, { initialValue: [] });
  
  selectedProductId = signal<string | null>(null);
  
  // Signal que obtiene el producto seleccionado del servidor
  selectedProduct = linkedSignal(() => {
    const id = this.selectedProductId();
    if (!id) {
      return this.getEmptyProduct();
    }
    return this.availableProducts().find(p => p.id === id) || this.getEmptyProduct();
  });
  
  // LinkedSignal: mantiene una copia local editable
  // Se resetea automáticamente cuando cambia selectedProduct
  editableProduct = linkedSignal(() => ({ ...this.selectedProduct() }));
  
  // Computed para detectar cambios
  hasChanges = signal(false);

  private getEmptyProduct(): Product {
    return {
      id: '',
      name: '',
      price: 0,
      stock: 0,
      category: ''
    };
  }

  loadProduct(event: Event) {
    const id = (event.target as HTMLSelectElement).value;
    this.selectedProductId.set(id || null);
    this.hasChanges.set(false);
  }

  updateField(field: keyof Product, event: Event) {
    const value = (event.target as HTMLInputElement).value;
    
    this.editableProduct.update(product => ({
      ...product,
      [field]: field === 'price' || field === 'stock' ? Number(value) : value
    }));
    
    this.hasChanges.set(true);
  }

  async saveProduct() {
    const product = this.editableProduct();
    
    try {
      if (product.id) {
        // Actualizar producto existente
        await this.http.put(`/api/products/${product.id}`, product).toPromise();
      } else {
        // Crear nuevo producto
        await this.http.post('/api/products', product).toPromise();
      }
      
      this.hasChanges.set(false);
      alert('Producto guardado exitosamente');
    } catch (error) {
      alert('Error al guardar el producto');
      console.error(error);
    }
  }

  resetForm() {
    // Forzar recálculo del linkedSignal
    this.selectedProductId.update(id => id);
    this.hasChanges.set(false);
  }
}

Sintaxis Avanzada de LinkedSignal

// Versión con acceso al valor anterior
const linkedValue = linkedSignal({
  source: () => sourceSignal(),
  computation: (source, previous) => {
    // 'previous' contiene el valor anterior si existe
    if (previous) {
      console.log('Valor anterior:', previous.value);
      console.log('Source anterior:', previous.source);
    }
    return processSource(source);
  }
});

Casos de Uso Ideales para LinkedSignal

Usa LinkedSignal cuando:

  • Necesitas una copia local editable de datos del servidor
  • Trabajas con formularios que se inicializan desde una API
  • Requieres valores por defecto que pueden ser sobrescritos por el usuario
  • Implementas funcionalidad de “reset” que vuelve a un valor calculado
  • Gestiones filtros que se pueden modificar pero también resetear automáticamente

No uses LinkedSignal cuando:

  • Un simple computed es suficiente
  • No necesitas modificar el valor manualmente
  • Los datos son completamente independientes

5. toSignal: Puente entre RxJS y Señales

¿Qué es toSignal?

toSignal es una función de utilidad que convierte Observables de RxJS en señales. Es fundamental para integrar las señales en Angular con código existente basado en RxJS, especialmente con HttpClient.

Sintaxis Básica

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-user-list',
  template: `
    <div>
      @if (users()) {
        @for (user of users(); track user.id) {
          <div class="user-card">
            <h3>{{ user.name }}</h3>
            <p>{{ user.email }}</p>
          </div>
        }
      } @else {
        <p>Cargando usuarios...</p>
      }
    </div>
  `
})
export class UserListComponent {
  private http = inject(HttpClient);
  
  // Convertir Observable a Signal
  users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] }
  );
}

Opciones de toSignal

1. initialValue: Valor inicial hasta que el Observable emita

const data = toSignal(observable$, { initialValue: [] });
// data() será [] hasta que observable$ emita

2. requireSync: Requiere emisión síncrona

const data = toSignal(of([1, 2, 3]), { requireSync: true });
// Solo funciona si el observable emite inmediatamente
// No incluye undefined en el tipo

3. Sin initialValue: El signal puede ser undefined

const data = toSignal(observable$);
// Tipo: Signal<Data | undefined>

Ejemplo de Producción: Búsqueda Reactiva

import { Component, signal, inject, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap, of } from 'rxjs';

interface SearchResult {
  id: string;
  title: string;
  description: string;
  thumbnail: string;
}

@Component({
  selector: 'app-search',
  template: `
    <div class="search-container">
      <h2>Búsqueda de Productos</h2>
      
      <input 
        type="text"
        [value]="searchTerm()"
        (input)="updateSearchTerm($event)"
        placeholder="Buscar productos..."
        class="search-input"
      />
      
      @if (isSearching()) {
        <div class="spinner">Buscando...</div>
      }
      
      @if (searchResults()) {
        <div class="results">
          <p>Encontrados {{ searchResults()!.length }} resultados</p>
          
          @for (result of searchResults(); track result.id) {
            <div class="result-card">
              <img [src]="result.thumbnail" [alt]="result.title" />
              <div class="result-content">
                <h3>{{ result.title }}</h3>
                <p>{{ result.description }}</p>
              </div>
            </div>
          }
          
          @if (searchResults()!.length === 0 && searchTerm()) {
            <p class="no-results">No se encontraron resultados</p>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .search-container {
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }
    
    .search-input {
      width: 100%;
      padding: 12px;
      font-size: 16px;
      border: 2px solid #ddd;
      border-radius: 8px;
    }
    
    .result-card {
      display: flex;
      gap: 16px;
      padding: 16px;
      border: 1px solid #eee;
      border-radius: 8px;
      margin-top: 12px;
    }
    
    .spinner {
      text-align: center;
      padding: 20px;
      color: #666;
    }
  `]
})
export class SearchComponent {
  private http = inject(HttpClient);
  
  // Estado de búsqueda
  searchTerm = signal('');
  isSearching = signal(false);
  
  // Crear Observable reactivo a partir de la señal
  private searchTerm$ = computed(() => this.searchTerm());
  
  // Convertir el resultado del Observable a Signal
  searchResults = toSignal(
    // Usar computed como Observable
    this.createSearchObservable(),
    { initialValue: [] }
  );

  private createSearchObservable() {
    return new Observable<string>(subscriber => {
      const subscription = computed(() => {
        subscriber.next(this.searchTerm());
      });
      // Esta sería una implementación simplificada
      // En producción usarías algo más robusto
    }).pipe(
      debounceTime(300),
      switchMap(term => {
        if (!term.trim()) {
          this.isSearching.set(false);
          return of([]);
        }
        
        this.isSearching.set(true);
        return this.http.get<SearchResult[]>(
          `/api/search?q=${encodeURIComponent(term)}`
        );
      })
    );
  }

  updateSearchTerm(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchTerm.set(value);
  }
}

Ejemplo Avanzado: Polling con toSignal

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, switchMap, startWith } from 'rxjs';

interface SystemStatus {
  cpu: number;
  memory: number;
  disk: number;
  timestamp: Date;
}

@Component({
  selector: 'app-system-monitor',
  template: `
    <div class="monitor">
      <h2>Monitor del Sistema</h2>
      
      @if (status(); as stat) {
        <div class="metrics">
          <div class="metric">
            <span class="label">CPU</span>
            <span class="value">{{ stat.cpu }}%</span>
            <div class="bar" [style.width.%]="stat.cpu"></div>
          </div>
          
          <div class="metric">
            <span class="label">Memoria</span>
            <span class="value">{{ stat.memory }}%</span>
            <div class="bar" [style.width.%]="stat.memory"></div>
          </div>
          
          <div class="metric">
            <span class="label">Disco</span>
            <span class="value">{{ stat.disk }}%</span>
            <div class="bar" [style.width.%]="stat.disk"></div>
          </div>
          
          <p class="timestamp">
            Última actualización: {{ stat.timestamp | date:'medium' }}
          </p>
        </div>
      }
    </div>
  `
})
export class SystemMonitorComponent {
  private http = inject(HttpClient);
  
  // Polling cada 5 segundos
  status = toSignal(
    interval(5000).pipe(
      startWith(0),
      switchMap(() => this.http.get<SystemStatus>('/api/system/status'))
    )
  );
}

Cuándo Usar toSignal

Usa toSignal cuando:

  • Trabajas con HttpClient y quieres señales en lugar de Observables
  • Integras bibliotecas RxJS existentes con el nuevo sistema de señales
  • Necesitas polling o streams de datos continuos como señales
  • Migras gradualmente de RxJS a señales

Consideraciones importantes:

  • El Observable debe completarse cuando el componente se destruye
  • Si el Observable nunca completa, usa takeUntilDestroyed()
  • Para Observables síncronos, considera requireSync: true

6. Resource API: Gestión Asíncrona de Datos (Experimental)

¿Qué es la Resource API?

La Resource API, introducida en Angular 19 y mejorada en Angular 20, es una forma reactiva de manejar operaciones asíncronas, especialmente peticiones HTTP. Aunque aún es experimental, representa el futuro de la carga de datos en Angular.

Características Clave

1. Estado Integrado: Rastrea automáticamente los estados loading, success, error 2. Cancelación Automática: Cancela peticiones pendientes cuando cambian los parámetros 3. Señales Nativas: Todo basado en señales, sin RxJS necesario 4. Copia Local: Proporciona una copia local editable de los datos

Sintaxis Básica

import { Component, signal, resource } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  template: `
    <div>
      @if (userResource.isLoading()) {
        <div class="loader">Cargando usuario...</div>
      }
      
      @if (userResource.hasValue()) {
        <div class="profile">
          <h2>{{ userResource.value().name }}</h2>
          <p>{{ userResource.value().email }}</p>
        </div>
      }
      
      @if (userResource.error()) {
        <div class="error">
          Error: {{ userResource.error().message }}
        </div>
      }
    </div>
  `
})
export class UserProfileComponent {
  userId = signal('123');
  
  userResource = resource({
    params: () => ({ id: this.userId() }),
    loader: async ({ params }) => {
      const response = await fetch(`/api/users/${params.id}`);
      if (!response.ok) throw new Error('Failed to load user');
      return response.json();
    }
  });
}

Estados del Resource

Un resource tiene varios estados y propiedades útiles:

// Estados booleanos
userResource.isLoading()    // true cuando está cargando
userResource.isIdle()       // true cuando no ha iniciado
userResource.isResolved()   // true cuando completó exitosamente
userResource.isReloading()  // true cuando recarga

// Valores
userResource.value()        // El valor cargado (puede lanzar error)
userResource.error()        // El error si falló
userResource.status()       // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error'

// Métodos
userResource.reload()       // Fuerza una recarga
userResource.hasValue()     // Type guard para el valor

Ejemplo de Producción: CRUD Completo con Resource API

import { Component, signal, resource, linkedSignal } from '@angular/core';

interface Post {
  id: string;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
}

@Component({
  selector: 'app-blog-posts',
  template: `
    <div class="blog-container">
      <h1>Gestión de Posts</h1>
      
      <!-- Filtros -->
      <div class="filters">
        <input 
          type="text"
          [value]="searchQuery()"
          (input)="updateSearch($event)"
          placeholder="Buscar posts..."
        />
        
        <select (change)="updateAuthor($event)">
          <option value="">Todos los autores</option>
          <option value="john">John Doe</option>
          <option value="jane">Jane Smith</option>
        </select>
        
        <button (click)="showCreateForm()">Nuevo Post</button>
      </div>
      
      <!-- Estados del Resource -->
      @if (postsResource.isLoading() && !postsResource.hasValue()) {
        <div class="loading-initial">
          <div class="spinner"></div>
          <p>Cargando posts...</p>
        </div>
      }
      
      @if (postsResource.error()) {
        <div class="error-banner">
          <h3>⚠️ Error al cargar posts</h3>
          <p>{{ postsResource.error().message }}</p>
          <button (click)="postsResource.reload()">Reintentar</button>
        </div>
      }
      
      <!-- Lista de Posts -->
      @if (postsResource.hasValue()) {
        <div class="posts-grid">
          @if (postsResource.isReloading()) {
            <div class="reloading-indicator">Actualizando...</div>
          }
          
          @for (post of editablePosts(); track post.id) {
            <div class="post-card">
              @if (editingPostId() === post.id) {
                <!-- Modo edición -->
                <form class="edit-form">
                  <input 
                    type="text"
                    [value]="post.title"
                    (input)="updatePostField(post.id, 'title', $event)"
                  />
                  <textarea
                    [value]="post.content"
                    (input)="updatePostField(post.id, 'content', $event)"
                  ></textarea>
                  <div class="form-actions">
                    <button type="button" (click)="savePost(post)">Guardar</button>
                    <button type="button" (click)="cancelEdit()">Cancelar</button>
                  </div>
                </form>
              } @else {
                <!-- Modo vista -->
                <h3>{{ post.title }}</h3>
                <p class="author">Por {{ post.author }}</p>
                <p class="content">{{ post.content }}</p>
                <p class="date">{{ post.createdAt | date:'short' }}</p>
                <div class="post-actions">
                  <button (click)="startEdit(post.id)">Editar</button>
                  <button (click)="deletePost(post.id)" class="danger">
                    Eliminar
                  </button>
                </div>
              }
            </div>
          }
          
          @if (editablePosts().length === 0) {
            <div class="empty-state">
              <p>No hay posts que coincidan con tu búsqueda</p>
            </div>
          }
        </div>
      }
      
      <!-- Modal de creación -->
      @if (showingCreateModal()) {
        <div class="modal-overlay" (click)="hideCreateForm()">
          <div class="modal-content" (click)="$event.stopPropagation()">
            <h2>Crear Nuevo Post</h2>
            <form (submit)="createPost($event)">
              <input 
                type="text" 
                [(ngModel)]="newPost.title"
                placeholder="Título"
                required
              />
              <textarea
                [(ngModel)]="newPost.content"
                placeholder="Contenido"
                required
              ></textarea>
              <input
                type="text"
                [(ngModel)]="newPost.author"
                placeholder="Autor"
                required
              />
              <div class="modal-actions">
                <button type="submit">Crear</button>
                <button type="button" (click)="hideCreateForm()">
                  Cancelar
                </button>
              </div>
            </form>
          </div>
        </div>
      }
    </div>
  `,
  styles: [`
    .blog-container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 20px;
    }
    
    .filters {
      display: flex;
      gap: 12px;
      margin-bottom: 24px;
    }
    
    .posts-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
      gap: 20px;
    }
    
    .post-card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
      background: white;
    }
    
    .loading-initial {
      text-align: center;
      padding: 40px;
    }
    
    .spinner {
      width: 40px;
      height: 40px;
      border: 4px solid #f3f3f3;
      border-top: 4px solid #3498db;
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin: 0 auto 16px;
    }
    
    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
  `]
})
export class BlogPostsComponent {
  // Filtros
  searchQuery = signal('');
  authorFilter = signal('');
  
  // Estados UI
  editingPostId = signal<string | null>(null);
  showingCreateModal = signal(false);
  
  newPost = {
    title: '',
    content: '',
    author: ''
  };
  
  // Resource para cargar posts
  postsResource = resource({
    params: () => ({
      search: this.searchQuery(),
      author: this.authorFilter()
    }),
    loader: async ({ params, abortSignal }) => {
      const queryParams = new URLSearchParams();
      if (params.search) queryParams.set('q', params.search);
      if (params.author) queryParams.set('author', params.author);
      
      const response = await fetch(
        `/api/posts?${queryParams.toString()}`,
        { signal: abortSignal }
      );
      
      if (!response.ok) {
        throw new Error('Error al cargar posts');
      }
      
      return response.json() as Promise<Post[]>;
    }
  });
  
  // LinkedSignal para tener copia editable
  editablePosts = linkedSignal(() => {
    if (!this.postsResource.hasValue()) return [];
    return [...this.postsResource.value()];
  });

  updateSearch(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchQuery.set(value);
  }

  updateAuthor(event: Event) {
    const value = (event.target as HTMLSelectElement).value;
    this.authorFilter.set(value);
  }

  startEdit(postId: string) {
    this.editingPostId.set(postId);
  }

  cancelEdit() {
    // Resetear cambios locales
    this.editablePosts.set([...this.postsResource.value()]);
    this.editingPostId.set(null);
  }

  updatePostField(postId: string, field: keyof Post, event: Event) {
    const value = (event.target as HTMLInputElement | HTMLTextAreaElement).value;
    
    this.editablePosts.update(posts =>
      posts.map(post =>
        post.id === postId
          ? { ...post, [field]: value }
          : post
      )
    );
  }

  async savePost(post: Post) {
    try {
      const response = await fetch(`/api/posts/${post.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post)
      });
      
      if (!response.ok) throw new Error('Error al guardar');
      
      // Recargar desde el servidor
      this.postsResource.reload();
      this.editingPostId.set(null);
    } catch (error) {
      alert('Error al guardar el post');
      console.error(error);
    }
  }

  async deletePost(postId: string) {
    if (!confirm('¿Estás seguro de eliminar este post?')) return;
    
    try {
      const response = await fetch(`/api/posts/${postId}`, {
        method: 'DELETE'
      });
      
      if (!response.ok) throw new Error('Error al eliminar');
      
      // Recargar la lista
      this.postsResource.reload();
    } catch (error) {
      alert('Error al eliminar el post');
      console.error(error);
    }
  }

  showCreateForm() {
    this.showingCreateModal.set(true);
  }

  hideCreateForm() {
    this.showingCreateModal.set(false);
    this.newPost = { title: '', content: '', author: '' };
  }

  async createPost(event: Event) {
    event.preventDefault();
    
    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.newPost)
      });
      
      if (!response.ok) throw new Error('Error al crear');
      
      this.hideCreateForm();
      this.postsResource.reload();
    } catch (error) {
      alert('Error al crear el post');
      console.error(error);
    }
  }
}

Resource API con Debouncing

import { Component, signal, resource } from '@angular/core';

@Component({
  selector: 'app-autocomplete',
  template: `
    <div class="autocomplete">
      <input 
        type="text"
        [value]="query()"
        (input)="updateQuery($event)"
        placeholder="Buscar..."
      />
      
      @if (resultsResource.isLoading()) {
        <div class="suggestions">
          <div class="loading">Buscando...</div>
        </div>
      }
      
      @if (resultsResource.hasValue() && query()) {
        <div class="suggestions">
          @for (item of resultsResource.value(); track item.id) {
            <div class="suggestion-item">{{ item.name }}</div>
          }
        </div>
      }
    </div>
  `
})
export class AutocompleteComponent {
  query = signal('');
  
  resultsResource = resource({
    params: () => ({ query: this.query() }),
    loader: async ({ params }) => {
      if (!params.query) return [];
      
      // Debouncing manual dentro del loader
      await new Promise(resolve => setTimeout(resolve, 300));
      
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(params.query)}`
      );
      return response.json();
    }
  });

  updateQuery(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.query.set(value);
  }
}

Cuándo Usar Resource API

Usa Resource API cuando:

  • Cargas datos asíncronos que dependen de parámetros reactivos
  • Necesitas gestión automática de estados de carga/error
  • Quieres cancelación automática de peticiones pendientes
  • Trabajas con operaciones CRUD y necesitas copias locales editables

Consideraciones:

  • Aún es experimental, puede cambiar
  • Ideal para operaciones de lectura (GET)
  • Para flujos complejos, RxJS puede ser más apropiado

Comparativa: Cuándo Usar Cada Tipo de Señal

Para facilitar la toma de decisiones, aquí hay una tabla comparativa:

TipoLecturaEscrituraReactivoUso Principal
WritableSignalEstado base modificable
ComputedValores derivados automáticos
EffectN/AN/AEfectos secundarios
LinkedSignalCopia editable con reset automático
toSignalConversión de Observable
Resource(Copia local)Carga asíncrona de datos

Patrones Avanzados con Señales en Angular

Patrón 1: Composición de Señales

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-advanced-dashboard',
  template: `<!-- template -->`
})
export class AdvancedDashboardComponent {
  // Señales base
  users = signal<User[]>([]);
  products = signal<Product[]>([]);
  orders = signal<Order[]>([]);
  
  // Señales derivadas compuestas
  statistics = computed(() => ({
    totalUsers: this.users().length,
    totalProducts: this.products().length,
    totalOrders: this.orders().length,
    averageOrderValue: this.calculateAverageOrderValue()
  }));
  
  topProducts = computed(() => {
    const ordersByProduct = this.groupOrdersByProduct();
    return this.products()
      .map(product => ({
        ...product,
        totalSold: ordersByProduct[product.id] || 0
      }))
      .sort((a, b) => b.totalSold - a.totalSold)
      .slice(0, 5);
  });

  private groupOrdersByProduct() {
    return this.orders().reduce((acc, order) => {
      acc[order.productId] = (acc[order.productId] || 0) + order.quantity;
      return acc;
    }, {} as Record<string, number>);
  }

  private calculateAverageOrderValue() {
    const orders = this.orders();
    if (orders.length === 0) return 0;
    const total = orders.reduce((sum, order) => sum + order.total, 0);
    return total / orders.length;
  }
}

Patrón 2: Signal Store Service

import { Injectable, signal, computed } from '@angular/core';

interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  notifications: Notification[];
  isLoading: boolean;
}

@Injectable({
  providedIn: 'root'
})
export class AppStateService {
  // Estado privado
  private state = signal<AppState>({
    user: null,
    theme: 'light',
    notifications: [],
    isLoading: false
  });

  // Selectores públicos (solo lectura)
  user = computed(() => this.state().user);
  theme = computed(() => this.state().theme);
  notifications = computed(() => this.state().notifications);
  isLoading = computed(() => this.state().isLoading);
  
  // Computed adicionales
  unreadNotifications = computed(() => 
    this.notifications().filter(n => !n.read).length
  );
  
  isAuthenticated = computed(() => this.user() !== null);

  // Métodos de actualización
  setUser(user: User | null) {
    this.state.update(s => ({ ...s, user }));
  }

  setTheme(theme: 'light' | 'dark') {
    this.state.update(s => ({ ...s, theme }));
    // Efecto secundario
    document.body.setAttribute('data-theme', theme);
  }

  addNotification(notification: Notification) {
    this.state.update(s => ({
      ...s,
      notifications: [...s.notifications, notification]
    }));
  }

  markNotificationAsRead(id: string) {
    this.state.update(s => ({
      ...s,
      notifications: s.notifications.map(n =>
        n.id === id ? { ...n, read: true } : n
      )
    }));
  }

  setLoading(isLoading: boolean) {
    this.state.update(s => ({ ...s, isLoading }));
  }
}

Patrón 3: Optimización con Funciones de Igualdad

import { signal, computed } from '@angular/core';

interface ComplexObject {
  id: string;
  data: any[];
  metadata: Record<string, any>;
}

// Custom equality function
const complexEqual = (a: ComplexObject, b: ComplexObject) => {
  return a.id === b.id && 
         a.data.length === b.data.length &&
         JSON.stringify(a.metadata) === JSON.stringify(b.metadata);
};

const complexData = signal<ComplexObject>(
  { id: '1', data: [], metadata: {} },
  { equal: complexEqual }
);

// El signal solo se marcará como cambiado si la función de igualdad retorna false

Mejores Prácticas con Señales en Angular

1. Granularidad Apropiada

// ❌ Evitar: Un objeto grande como señal única
const appState = signal({
  users: [],
  products: [],
  orders: [],
  settings: {}
});

// ✅ Mejor: Señales individuales
const users = signal([]);
const products = signal([]);
const orders = signal([]);
const settings = signal({});

2. Computed para Transformaciones

// ❌ Evitar: Transformar en el template
<div>{{ users().filter(u => u.active).length }}</div>

// ✅ Mejor: Usar computed
activeUsersCount = computed(() => 
  this.users().filter(u => u.active).length
);

<div>{{ activeUsersCount() }}</div>

3. Effects Solo para Side Effects

// ❌ Evitar: Usar effect para derivar valores
constructor() {
  effect(() => {
    this.total = this.price() * this.quantity(); // ❌
  });
}

// ✅ Mejor: Usar computed
total = computed(() => this.price() * this.quantity());

4. Inmutabilidad en Updates

// ❌ Evitar: Mutar el array directamente
addItem(item: Item) {
  this.items().push(item); // ❌ No funcionará
}

// ✅ Mejor: Crear nuevo array
addItem(item: Item) {
  this.items.update(items => [...items, item]);
}

5. Naming Conventions

// Señales: sustantivos o adjetivos
const users = signal<User[]>([]);
const isLoading = signal(false);

// Computed: descriptivos
const activeUsers = computed(() => ...);
const totalPrice = computed(() => ...);

// Effects: verbos de acción
effect(() => {
  saveToLocalStorage(this.preferences());
});

Migración de Código Legacy a Señales

De @Input() a Signal Inputs

// Antes (Angular tradicional)
@Component({})
export class UserComponent {
  @Input() userId!: string;
  @Input() showDetails = false;
}

// Después (Angular con señales)
@Component({})
export class UserComponent {
  userId = input.required<string>();
  showDetails = input(false);
}

De Observable a Señales

// Antes
export class DataComponent implements OnInit, OnDestroy {
  data$ = new BehaviorSubject<Data[]>([]);
  filteredData$ = this.data$.pipe(
    map(data => data.filter(d => d.active))
  );
  private destroy$ = new Subject<void>();

  ngOnInit() {
    this.http.get<Data[]>('/api/data')
      .pipe(takeUntil(this.destroy$))
      .subscribe(data => this.data$.next(data));
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Después
export class DataComponent {
  private http = inject(HttpClient);
  
  dataResource = resource({
    loader: () => this.http.get<Data[]>('/api/data').toPromise()
  });
  
  filteredData = computed(() => 
    this.dataResource.value()?.filter(d => d.active) ?? []
  );
}

Rendimiento y Optimización

Detección de Cambios con Señales

Las señales en Angular permiten una detección de cambios mucho más eficiente:

Antes (Zone.js):

  • Cualquier evento activa detección de cambios en todo el árbol
  • Se verifican todos los componentes, incluso los que no cambiaron
  • Mayor overhead de memoria y CPU

Después (Señales + Zoneless):

  • Solo se actualizan las vistas que leen señales modificadas
  • Detección de cambios quirúrgica y precisa
  • Hasta 30% de reducción en el bundle size

Ejemplo de Optimización con OnPush

import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-optimized-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (item of visibleItems(); track item.id) {
      <app-list-item [item]="item" />
    }
  `
})
export class OptimizedListComponent {
  items = signal<Item[]>([]);
  filter = signal('');
  
  // Computed se recalcula solo cuando items o filter cambian
  visibleItems = computed(() => {
    const filterValue = this.filter().toLowerCase();
    return this.items().filter(item =>
      item.name.toLowerCase().includes(filterValue)
    );
  });
}

Conclusión

Las señales en Angular representan un cambio paradigmático en cómo construimos aplicaciones con Angular. Con Angular 20, el sistema de señales ha madurado hasta convertirse en una solución estable, performante y elegante para la gestión de estado reactivo.

Resumen de Tipos de Señales

  1. WritableSignal: Base para estado mutable
  2. Computed: Valores derivados automáticos
  3. Effect: Efectos secundarios reactivos
  4. LinkedSignal: Señales editables con reset automático
  5. toSignal: Puente con RxJS
  6. Resource API: Gestión asíncrona moderna

Ventajas Clave de las Señales en Angular

  • Rendimiento mejorado con detección de cambios granular
  • Código más limpio sin boilerplate de RxJS
  • Mejor debugging con trazas más claras
  • Bundles más pequeños eliminando Zone.js
  • Experiencia de desarrollo superior

El Futuro de Angular

Angular 20 marca el comienzo de una nueva era. Las señales en Angular no son solo una mejora incremental, son la base sobre la cual se construirá el framework en los próximos años. Con características como zoneless mode, incremental hydration, y signal-based forms en el horizonte, Angular se posiciona como uno de los frameworks más modernos y performantes del ecosistema.

Si estás construyendo nuevas aplicaciones Angular o actualizando aplicaciones existentes, adoptar las señales en Angular es una inversión que pagará dividendos en mantenibilidad, rendimiento y experiencia de desarrollo. El futuro de Angular es reactivo, y ese futuro ya está aquí.


Palabras clave: Señales en Angular, Angular 20, Angular Signals, WritableSignal, Computed Signals, Effect Angular, LinkedSignal, toSignal, Resource API Angular, reactividad Angular, gestión de estado Angular, detección de cambios Angular, zoneless Angular, Angular performance

Meta descripción: Guía completa sobre señales en Angular 20. Aprende todos los tipos de signals (WritableSignal, Computed, Effect, LinkedSignal, toSignal, Resource API) con ejemplos de producción y mejores prácticas.

Leave a Comment

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *