Este conteúdo está disponível apenas em Espanhol.

Também está disponível em Português.

Ver tradução
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

Tags do artigo

Artigos relacionados

Receba os ultimos artigos no seu email.

Follow Us: