Frontend

Guía de Angular Signals que Transformará tu Programación Reactiva

Finalmente, un primitivo reactivo que hace la gestión de estado intuitiva y performante sin la complejidad de RxJS

Equipe Blueprintblog12 min
Guía de Angular Signals que Transformará tu Programación Reactiva

¡Hola, developers! 👋

¿Recuerdas la última vez que debugueaste una cadena compleja de RxJS con múltiples suscripciones, async pipes por todos lados, y ese memory leak que no podías rastrear? ¿O cuando tuviste que explicarle a un desarrollador junior por qué necesita hacer unsubscribe de los observables??

Hoy, quiero compartir Angular Signals - un nuevo primitivo reactivo que cambia fundamentalmente cómo manejamos el estado en aplicaciones Angular. Al final de este artículo, entenderás cómo aprovechar signals para tener programación reactiva más limpia y performante sin la sobrecarga tradicional de RxJS.

¿Qué son los Angular Signals?

Piensa en signals como "variables inteligentes" que automáticamente rastrean cuándo son leídas y notifican cuando cambian. Son como un rastreador GPS para tus datos - siempre sabiendo quién está observando y actualizando eficientemente solo lo que necesita cambiar.

Angular Signals resuelven el problema fundamental de la reactividad granular: saber exactamente qué cambió y actualizar solo las partes afectadas de tu UI, sin gestión manual de suscripciones o preocupaciones con change detection.

¿Cuándo Deberías Usar Angular Signals?

Buenos casos de uso:

  • Gestión de estado de componentes que necesita actualizaciones reactivas
  • Valores computados derivados de otras fuentes reactivas
  • Estado y lógica de validación de formularios
  • Estado compartido entre componentes sin servicios
  • Actualizaciones de UI críticas para performance con change detection mínimo

Cuándo NO usar Signals:

  • Peticiones HTTP y operaciones asíncronas (continúa con Observables)
  • Streams de eventos complejos que necesitan operators como debounce, throttle
  • Integración con codebases pesadas en RxJS (usa interop con cuidado)

Signals: Tu Primera Implementación

Construyamos un ejemplo práctico: un contador de productos con cálculo de precio en tiempo real que demuestra los conceptos principales de signals.

Paso 1: Creando Tu Primer Signal

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

@Component({
  selector: 'app-producto',
  template: `
    <div>
      <h2>Producto: {{ nombreProducto() }}</h2>
      <p>Cantidad: {{ cantidad() }}</p>
      <button (click)="incrementar()">Agregar al Carrito</button>
    </div>
  `
})
export class ProductoComponent {
  // Creando signals escribibles
  nombreProducto = signal('Libro Angular');
  cantidad = signal(0);
  
  incrementar() {
    // Actualizando valor del signal
    this.cantidad.set(this.cantidad() + 1);
  }
}

Este código crea dos signals - nota cómo los llamamos como funciones en el template. Los signals son funciones que retornan su valor actual cuando son llamadas.

Paso 2: Trabajando con Computed Signals

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

@Component({
  selector: 'app-producto',
  template: `
    <div>
      <p>Cantidad: {{ cantidad() }}</p>
      <p>Precio unitario: ${{ precioUnitario() }}</p>
      <p>Total: ${{ precioTotal() }}</p>
      <button (click)="incrementar()">Agregar Item</button>
    </div>
  `
})
export class ProductoComponent {
  cantidad = signal(1);
  precioUnitario = signal(29.99);
  
  // Computed signal actualiza automáticamente cuando las dependencias cambian
  precioTotal = computed(() => {
    return this.cantidad() * this.precioUnitario();
  });
  
  incrementar() {
    this.cantidad.update(c => c + 1);
  }
}

Los computed signals recalculan automáticamente cuando sus dependencias cambian. Sin suscripciones, sin actualizaciones manuales - simplemente funciona.

Paso 3: Signal Effects para Side Effects

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

@Component({
  selector: 'app-producto'
})
export class ProductoComponent {
  cantidad = signal(0);
  inventario = signal(10);
  
  constructor() {
    // Effect ejecuta cuando los signals que lee cambian
    effect(() => {
      if (this.cantidad() > this.inventario()) {
        console.log('¡Advertencia: La cantidad excede el inventario!');
        this.mostrarAdvertenciaInventario = true;
      }
    });
  }
  
  agregarAlCarrito() {
    if (this.cantidad() < this.inventario()) {
      this.cantidad.update(c => c + 1);
    }
  }
}

Los effects rastrean automáticamente las dependencias de signals y se re-ejecutan cuando esos signals cambian - perfecto para logging, analytics, o manipulaciones del DOM.

Un Ejemplo Más Complejo: Carrito de Compras con Filtros

Construyamos algo más realista - un carrito de compras con filtrado y cálculos en tiempo real:

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

interface Producto {
  id: number;
  nombre: string;
  precio: number;
  categoria: string;
  enStock: boolean;
}

@Component({
  selector: 'app-carrito-compras',
  template: `
    <div class="carrito">
      <input 
        placeholder="Buscar productos..." 
        (input)="terminoBusqueda.set($event.target.value)"
      />
      
      <select (change)="categoriaSeleccionada.set($event.target.value)">
        <option value="todas">Todas las Categorías</option>
        <option *ngFor="let cat of categorias()" [value]="cat">
          {{ cat }}
        </option>
      </select>
      
      <div class="productos">
        <div *ngFor="let producto of productosFiltrados()">
          <h3>{{ producto.nombre }}</h3>
          <p>${{ producto.precio }}</p>
          <button 
            (click)="agregarAlCarrito(producto)"
            [disabled]="!producto.enStock"
          >
            Agregar al Carrito
          </button>
        </div>
      </div>
      
      <div class="resumen">
        <p>Items en el carrito: {{ itemsCarrito().length }}</p>
        <p>Total: ${{ totalCarrito() }}</p>
        <p>Con impuesto (10%): ${{ totalConImpuesto() }}</p>
      </div>
    </div>
  `
})
export class CarritoComprasComponent {
  // Signals de estado
  productos = signal<Producto[]>([
    { id: 1, nombre: 'Portátil', precio: 999, categoria: 'Electrónica', enStock: true },
    { id: 2, nombre: 'Ratón', precio: 29, categoria: 'Electrónica', enStock: true },
    { id: 3, nombre: 'Escritorio', precio: 299, categoria: 'Muebles', enStock: false },
    { id: 4, nombre: 'Silla', precio: 199, categoria: 'Muebles', enStock: true }
  ]);
  
  itemsCarrito = signal<Producto[]>([]);
  terminoBusqueda = signal('');
  categoriaSeleccionada = signal('todas');
  
  // Computed signals para estado derivado
  categorias = computed(() => {
    const cats = new Set(this.productos().map(p => p.categoria));
    return Array.from(cats);
  });
  
  productosFiltrados = computed(() => {
    const termino = this.terminoBusqueda().toLowerCase();
    const categoria = this.categoriaSeleccionada();
    
    return this.productos().filter(producto => {
      const coincideBusqueda = producto.nombre.toLowerCase().includes(termino);
      const coincideCategoria = categoria === 'todas' || producto.categoria === categoria;
      return coincideBusqueda && coincideCategoria;
    });
  });
  
  totalCarrito = computed(() => {
    return this.itemsCarrito().reduce((suma, item) => suma + item.precio, 0);
  });
  
  totalConImpuesto = computed(() => {
    return this.totalCarrito() * 1.1; // 10% impuesto
  });
  
  agregarAlCarrito(producto: Producto) {
    this.itemsCarrito.update(items => [...items, producto]);
  }
}

Este ejemplo muestra cómo los signals manejan elegantemente relaciones reactivas complejas sin gestión manual de suscripciones.

Patrón Avanzado: Validación de Formulario Basada en Signals

Construyamos algo aún más sofisticado - un formulario reactivo con validación en tiempo real usando signals:

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

interface ErroresFormulario {
  email?: string;
  contrasena?: string;
  confirmarContrasena?: string;
}

@Component({
  selector: 'app-formulario-registro',
  template: `
    <form (submit)="enviarFormulario($event)">
      <div>
        <input 
          type="email" 
          placeholder="Email"
          [value]="email()"
          (input)="email.set($event.target.value)"
          [class.error]="errores().email"
        />
        <span class="msg-error">{{ errores().email }}</span>
      </div>
      
      <div>
        <input 
          type="password" 
          placeholder="Contraseña"
          [value]="contrasena()"
          (input)="contrasena.set($event.target.value)"
          [class.error]="errores().contrasena"
        />
        <span class="msg-error">{{ errores().contrasena }}</span>
      </div>
      
      <div>
        <input 
          type="password" 
          placeholder="Confirmar Contraseña"
          [value]="confirmarContrasena()"
          (input)="confirmarContrasena.set($event.target.value)"
          [class.error]="errores().confirmarContrasena"
        />
        <span class="msg-error">{{ errores().confirmarContrasena }}</span>
      </div>
      
      <button [disabled]="!formularioValido()">
        Registrarse
      </button>
      
      <div class="medidor-fuerza">
        Fuerza de la Contraseña: {{ fuerzaContrasena() }}
      </div>
    </form>
  `
})
export class FormularioRegistroComponent {
  // Signals de campos del formulario
  email = signal('');
  contrasena = signal('');
  confirmarContrasena = signal('');
  camposTocados = signal<Set<string>>(new Set());
  
  // Reglas de validación como computed signals
  errores = computed<ErroresFormulario>(() => {
    const errores: ErroresFormulario = {};
    const tocados = this.camposTocados();
    
    // Validación de email
    if (tocados.has('email')) {
      const valorEmail = this.email();
      if (!valorEmail) {
        errores.email = 'El email es obligatorio';
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(valorEmail)) {
        errores.email = 'Formato de email inválido';
      }
    }
    
    // Validación de contraseña
    if (tocados.has('contrasena')) {
      const pwd = this.contrasena();
      if (!pwd) {
        errores.contrasena = 'La contraseña es obligatoria';
      } else if (pwd.length < 8) {
        errores.contrasena = 'La contraseña debe tener al menos 8 caracteres';
      }
    }
    
    // Validación de confirmación de contraseña
    if (tocados.has('confirmarContrasena')) {
      if (this.contrasena() !== this.confirmarContrasena()) {
        errores.confirmarContrasena = 'Las contraseñas no coinciden';
      }
    }
    
    return errores;
  });
  
  fuerzaContrasena = computed(() => {
    const pwd = this.contrasena();
    if (pwd.length < 6) return 'Débil';
    if (pwd.length < 10) return 'Media';
    if (/[A-Z]/.test(pwd) && /[0-9]/.test(pwd) && /[^A-Za-z0-9]/.test(pwd)) {
      return 'Fuerte';
    }
    return 'Media';
  });
  
  formularioValido = computed(() => {
    return this.email() && 
           this.contrasena() && 
           this.confirmarContrasena() &&
           Object.keys(this.errores()).length === 0;
  });
  
  constructor() {
    // Auto-guardar borrador en localStorage
    effect(() => {
      const borrador = {
        email: this.email(),
        timestamp: Date.now()
      };
      localStorage.setItem('borradorRegistro', JSON.stringify(borrador));
    });
  }
  
  marcarComoTocado(campo: string) {
    this.camposTocados.update(campos => {
      campos.add(campo);
      return new Set(campos);
    });
  }
  
  enviarFormulario(event: Event) {
    event.preventDefault();
    if (this.formularioValido()) {
      console.log('Formulario enviado:', {
        email: this.email(),
        contrasena: this.contrasena()
      });
    }
  }
}

Esto demuestra cómo los signals pueden reemplazar librerías de formularios complejas con lógica de validación simple y reactiva.

Angular Signals con TypeScript

Para usuarios de TypeScript, aquí está cómo hacer tus implementaciones de signal type-safe:

javascript
// tipos.ts
interface Usuario {
  id: number;
  nombre: string;
  email: string;
  rol: 'admin' | 'usuario' | 'invitado';
}

interface EstadoApp {
  usuarioActual: Usuario | null;
  estaAutenticado: boolean;
  permisos: string[];
}

// signal-store.service.ts
import { Injectable, signal, computed, Signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class SignalStore {
  // Signals escribibles con tipos explícitos
  private _usuarioActual = signal<Usuario | null>(null);
  private _estaCargando = signal<boolean>(false);
  
  // Computed signals de solo lectura
  readonly usuarioActual: Signal<Usuario | null> = this._usuarioActual.asReadonly();
  readonly estaAutenticado = computed<boolean>(() => !!this._usuarioActual());
  
  readonly permisos = computed<string[]>(() => {
    const usuario = this._usuarioActual();
    if (!usuario) return [];
    
    switch(usuario.rol) {
      case 'admin': return ['leer', 'escribir', 'eliminar', 'admin'];
      case 'usuario': return ['leer', 'escribir'];
      case 'invitado': return ['leer'];
    }
  });
  
  // Métodos de actualización type-safe
  login(usuario: Usuario): void {
    this._usuarioActual.set(usuario);
  }
  
  actualizarUsuario(actualizaciones: Partial<Usuario>): void {
    this._usuarioActual.update(actual => 
      actual ? { ...actual, ...actualizaciones } : null
    );
  }
}

// Uso con TypeScript
@Component({
  selector: 'app-perfil',
  template: `
    <div *ngIf="store.usuarioActual() as usuario">
      <h2>{{ usuario.nombre }}</h2>
      <p>Rol: {{ usuario.rol }}</p>
      <ul>
        <li *ngFor="let perm of store.permisos()">
          {{ perm }}
        </li>
      </ul>
    </div>
  `
})
export class PerfilComponent {
  constructor(public store: SignalStore) {}
}

Patrones Avanzados y Mejores Prácticas

1. Patrón de Composición de Signals

Crea signals de orden superior que combinan múltiples fuentes de signals:

javascript
// Componer múltiples signals en un único estado reactivo
function crearListaPaginada<T>(items: Signal<T[]>, tamañoPagina: number) {
  const paginaActual = signal(0);
  
  const totalPaginas = computed(() => 
    Math.ceil(items().length / tamañoPagina)
  );
  
  const itemsPaginados = computed(() => {
    const inicio = paginaActual() * tamañoPagina;
    return items().slice(inicio, inicio + tamañoPagina);
  });
  
  return {
    items: itemsPaginados,
    paginaActual: paginaActual.asReadonly(),
    totalPaginas,
    siguientePagina: () => paginaActual.update(p => Math.min(p + 1, totalPaginas() - 1)),
    paginaAnterior: () => paginaActual.update(p => Math.max(p - 1, 0))
  };
}

2. Patrón de Memoización de Signals

Optimiza cálculos costosos con signals memoizados:

javascript
// Memoizar operaciones costosas
function crearSignalMemoizado<T, R>(
  fuente: Signal<T>,
  calcular: (valor: T) => R,
  igualdad?: (a: R, b: R) => boolean
) {
  let ultimaEntrada: T | undefined;
  let ultimaSalida: R | undefined;
  
  return computed(() => {
    const actual = fuente();
    if (ultimaEntrada === actual && ultimaSalida !== undefined) {
      return ultimaSalida;
    }
    ultimaEntrada = actual;
    ultimaSalida = calcular(actual);
    return ultimaSalida;
  }, { equal: igualdad });
}

3. Patrón de Debouncing de Signals

Implementa signals con debounce para búsqueda y manejo de inputs:

javascript
// Signal con debounce para inputs de búsqueda
function crearSignalConDebounce<T>(valorInicial: T, retraso: number) {
  const inmediato = signal(valorInicial);
  const conDebounce = signal(valorInicial);
  let timeoutId: any;
  
  const definir = (valor: T) => {
    inmediato.set(valor);
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      conDebounce.set(valor);
    }, retraso);
  };
  
  return {
    inmediato: inmediato.asReadonly(),
    conDebounce: conDebounce.asReadonly(),
    definir
  };
}

// Uso
const busqueda = crearSignalConDebounce('', 300);
// busqueda.inmediato() - valor instantáneo
// busqueda.conDebounce() - valor con debounce para llamadas API

4. Patrón de Máquina de Estados con Signals

Construye máquinas de estados robustas con signals:

javascript
// Máquina de estados usando signals
function crearMaquinaDeEstados<T extends string>(
  estadoInicial: T,
  transiciones: Record<T, T[]>
) {
  const estadoActual = signal(estadoInicial);
  
  const puedeTransicionarA = computed(() => {
    return transiciones[estadoActual()] || [];
  });
  
  const transicionarA = (nuevoEstado: T) => {
    if (puedeTransicionarA().includes(nuevoEstado)) {
      estadoActual.set(nuevoEstado);
      return true;
    }
    return false;
  };
  
  return {
    estado: estadoActual.asReadonly(),
    puedeTransicionarA,
    transicionarA
  };
}

Errores Comunes a Evitar

1. Mutar Objetos Dentro de Signals

javascript
// ❌ No hagas esto - mutar objeto no dispara actualizaciones
const usuario = signal({ nombre: 'Juan', edad: 30 });
usuario().nombre = 'María'; // ¡Esto no disparará change detection!

// ✅ Haz esto en su lugar - crea nueva referencia de objeto
usuario.update(u => ({ ...u, nombre: 'María' }));
// O
usuario.set({ ...usuario(), nombre: 'María' });

2. Crear Signals Dentro de Computed

javascript
// ❌ Ejemplo problemático - crea nuevo signal en cada cálculo
const computedMalo = computed(() => {
  const signalTemp = signal(0); // ¡No crees signals aquí!
  return signalTemp() + otroSignal();
});

// ✅ Solución - crea signals fuera de computed
const signalTemp = signal(0);
const computedBueno = computed(() => {
  return signalTemp() + otroSignal();
});

3. Olvidar Llamar Funciones Signal

tsx
// ❌ Evita este patrón - olvidando paréntesis
@Component({
  template: `<div>{{ contador }}</div>` // ¡No se actualizará!
})
export class ComponenteMalo {
  contador = signal(0);
}

// ✅ Enfoque preferido - siempre llama signals como funciones
@Component({
  template: `<div>{{ contador() }}</div>` // Propiamente reactivo
})
export class ComponenteBueno {
  contador = signal(0);
}

Cuándo NO Usar Signals

No uses signals cuando:

  • Trabajando con peticiones HTTP - Los Observables manejan mejor las operaciones asíncronas
  • Necesites operators de stream complejos (debounce, throttle, retry) - RxJS es más poderoso
  • Integrando con APIs existentes basadas en Observable - sobrecarga de conversión innecesaria
javascript
// ❌ Exageración para escenarios simples
const resultadoHttp = signal<Datos | null>(null);
this.http.get('/api/datos').subscribe(datos => {
  resultadoHttp.set(datos); // Conversión innecesaria
});

// ✅ Solución simple es mejor
datos$ = this.http.get('/api/datos');
// Usa async pipe en el template

Signals vs RxJS Observables

Los Signals son geniales para:

  • Gestión de estado síncrono
  • Valores computados simples
  • Actualizaciones de UI críticas para performance
  • Reducir código boilerplate

Considera Observables cuando necesites:

  • Operaciones asíncronas → Peticiones HTTP, streams WebSocket
  • Operators complejos → debounceTime, switchMap, retry
  • Streams de eventos → fromEvent, interval, timer

Conclusión

Angular Signals son una herramienta poderosa que puede simplificar dramáticamente la gestión de estado en tus aplicaciones. Traen reactividad granular, rastreo automático de dependencias, y mejor rendimiento a tus componentes Angular.

Puntos clave:

  • Los signals son funciones que mantienen y rastrean valores reactivos
  • Los computed signals derivan automáticamente estado de otros signals
  • Los effects manejan side effects con rastreo automático de dependencias
  • Los signals eliminan la gestión manual de suscripciones y memory leaks

La próxima vez que vayas a usar un Subject o BehaviorSubject para estado de componente, recuerda los signals. Tu código será más limpio, más performante, y más fácil de entender.

¿Ya has empezado a usar signals en tus proyectos Angular? ¿Qué patrones has descubierto? ¡Comparte tus experiencias en los comentarios!


Si esto te ayudó a mejorar tus habilidades en Angular, ¡sígueme para más patrones y mejores prácticas modernas de Angular! 🚀

Recursos

Etiquetas del articulo

Articulos relacionados

Recibe los ultimos articulos en tu correo.

Follow Us: