Codea Bien Logo

Angular Signals: Un recap a la evolución del framework

Angular, en su constante evolución, ha introducido uno de los cambios más significativos en su sistema de reactividad en años: los Signals. Aunque llevan ya un tiempo entre nosotros desde su introducción en la versión 16 y su consolidación en la 17, todavía existe una parte de la comunidad de desarrolladores que no ha profundizado en ellos. Seamos claros: entender y adoptar Signals no es opcional si quieres escribir aplicaciones Angular modernas, eficientes y mantenibles. Es un cambio fundamental que mejora drásticamente el flujo de desarrollo y el rendimiento de las aplicaciones.

Este artículo es una guía completa para que entiendas qué son, por qué son tan importantes y cómo puedes empezar a usarlos hoy mismo en tus proyectos.

¿Qué es un Signal?

En su forma más simple, un Signal es un contenedor (wrapper) para un valor que puede notificar a los consumidores interesados cuando ese valor cambia. Piensa en una celda de una hoja de cálculo. Si la celda B1 tiene la fórmula =A1 * 2, cada vez que el valor de A1 cambia, B1 se actualiza automáticamente. B1 está "reaccionando" a los cambios de A1.

Eso es exactamente lo que hace un Signal. Es un sistema de reactividad granular que permite a Angular saber exactamente qué partes de la interfaz de usuario necesitan actualizarse cuando un estado cambia, sin tener que revisar todo el árbol de componentes.

¿Por qué necesitamos Signals? El adiós a Zone.js

Históricamente, Angular ha dependido de una librería llamada Zone.js para la detección de cambios. Zone.js "parcha" (monkey-patches) casi todos los eventos asíncronos del navegador (como setTimeout, clics, peticiones HTTP) para saber cuándo "algo" podría haber cambiado en la aplicación. Cuando se dispara un evento, le dice a Angular: "Bro, algo pasó, deberías revisar toda tu aplicación, por si acaso".

Este enfoque, aunque ingenioso, tiene desventajas:

  1. Rendimiento: Revisar todo el árbol de componentes puede ser costoso en aplicaciones grandes.

  2. Complejidad: A veces, el comportamiento de Zone.js puede parecer mágico o impredecible.

  3. Sobrecarga: Incluye una librería externa que aumenta el tamaño del bundle.

Los Signals resuelven esto. Con ellos, la detección de cambios se vuelve explícita y localizada. Si un signal cambia, solo los computed y effects que dependen directamente de él se ejecutarán, y solo los componentes en la vista que usan ese signal se volverán a renderizar. Esto abre la puerta a un futuro "Zone-less" en Angular, con aplicaciones increíblemente rápidas y un flujo de estado mucho más predecible.

Conociendo a los Actores Principales

Los Signals no son solo un tipo de objeto, sino un conjunto de herramientas que trabajan juntas.

signal() y WritableSignal

Esta es la base de todo. La función signal() crea un nuevo signal mutable, conocido como WritableSignal. Puedes leer su valor y también escribir en él.

  • Creación: const mySignal = signal('initial value');

  • Lectura: Para obtener el valor, debes ejecutar el signal como una función: console.log(mySignal());

  • Escritura: Un WritableSignal tiene tres métodos para cambiar su valor:

    • .set(newValue): Reemplaza el valor actual por completo.

    • .update(fn): Calcula el nuevo valor a partir del valor actual. Es ideal para estados complejos como arrays u objetos.

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

// Create a writable signal
const count = signal(0);

// Read the value
console.log(count()); // Output: 0

// Set a new value
count.set(5);
console.log(count()); // Output: 5

// Update the value based on the previous one
count.update(currentValue => currentValue + 1);
console.log(count()); // Output: 6

computed()

Un computed signal es un signal de solo lectura cuyo valor se deriva de otros signals. Es perezoso (lazy) y se memoriza (memoized). Esto significa que su valor solo se recalcula si uno de los signals de los que depende ha cambiado. Es perfecto para crear estado derivado sin coste de rendimiento.

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

const price = signal(10);
const quantity = signal(2);

// Create a computed signal
const total = computed(() => price() * quantity());

console.log(total()); // Output: 20

price.set(15);
// `total` is now marked as stale, but not recalculated yet

console.log(total()); // Now it recalculates. Output: 30

effect()

Un effect es una operación que se ejecuta cada vez que uno de los signals que lee en su interior cambia. Los effect son perfectos para manejar efectos secundarios (side effects), como:

  • Logging de datos.

  • Sincronización con localStorage.

  • Manipulación manual del DOM.

  • Llamadas a una API (con cuidado).

Es importante recordar: no uses un effect para cambiar otros signals. Para eso está computed. Un effect se crea dentro de un contexto de inyección (como el constructor de un componente), lo que lo vincula automáticamente al ciclo de vida del componente.

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

@Component({
  /* ... */
})
export class LoggerComponent {
  count = signal(0);

  constructor() {
    // This effect runs whenever `this.count` changes
    effect(() => {
      console.log(`The current count is: ${this.count()}`);
    });
  }
}

untracked()

A veces, dentro de un effect o computed, necesitas leer el valor de un signal sin crear una dependencia. Para eso sirve untracked. La función que lo usa no se volverá a ejecutar si el signal "no rastreado" cambia.

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

const currentUser = signal('John');
const counter = signal(0);

effect(() => {
  // This effect depends on `counter` but NOT on `currentUser`
  console.log(`Counter changed to ${counter()}. User is ${untracked(currentUser)}`);
});

counter.set(1); // The effect runs.
currentUser.set('Jane'); // The effect DOES NOT run.

Como se Usan en la Práctica: Ejemplo de un Carrito de Compras

Veamos cómo todos estos conceptos se unen en un componente de Angular moderno y auto-contenido (standalone). Crearemos un pequeño carrito de compras.

// cart.component.ts
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

// Define the shape of our data
interface Product {
  id: number;
  name: string;
  price: number;
}

interface CartItem {
  product: Product;
  quantity: number;
}

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Available Products</h2>
    <ul>
      @for(product of availableProducts(); track product.id) {
        <li>
          {{ product.name }} - \&#36;{{ product.price }}
          <button (click)="addToCart(product)">Add to Cart</button>
        </li>
      }
    </ul>

    <hr>

    <h2>My Cart</h2>
    @if(cartItems().length > 0) {
      <ul>
        @for(item of cartItems(); track item.product.id) {
          <li>
            {{ item.product.name }} (x{{ item.quantity }})
            - \&#36;{{ item.product.price * item.quantity }}
          </li>
        }
      </ul>
      <h3>Total Items: {{ totalItems() }}</h3>
      <h3>Total Price: \&#36;{{ totalPrice() }}</h3>
    } @else {
      <p>Your cart is empty.</p>
    }
  &#96;,
})
export class CartComponent {
  // Our source of truth: a list of available products
  availableProducts = signal<Product[]>([
    { id: 1, name: 'Super Keyboard', price: 99.99 },
    { id: 2, name: 'Awesome Mouse', price: 49.50 },
    { id: 3, name: 'HD Monitor', price: 299.00 },
  ]);

  // The state of our cart: a writable signal
  cartItems = signal<CartItem[]>([]);

  // --- DERIVED STATE USING COMPUTED SIGNALS ---

  // Total items in the cart
  totalItems = computed(() =>
    this.cartItems().reduce((sum, item) => sum + item.quantity, 0)
  );

  // Total price of the cart
  totalPrice = computed(() =>
    this.cartItems().reduce(
      (sum, item) => sum + item.product.price * item.quantity,
      0
    )
  );

  // --- ACTIONS TO MODIFY STATE ---

  addToCart(product: Product) {
    this.cartItems.update(items => {
      const itemInCart = items.find(item => item.product.id === product.id);
      if (itemInCart) {
        // Product already in cart, just increase quantity
        return items.map(item =>
          item.product.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        // Add new product to cart
        return [...items, { product, quantity: 1 }];
      }
    });
  }
}

En este ejemplo:

  • cartItems es nuestro WritableSignal, la única fuente de verdad para el estado del carrito.

  • totalItems y totalPrice son computed signals. No tenemos que actualizarlos manualmente; reaccionan automáticamente a cualquier cambio en cartItems.

  • El método addToCart usa .update() para modificar el estado de forma inmutable, lo cual es una buena práctica.

  • La plantilla (template) utiliza la nueva sintaxis de @for y @if y lee directamente los signals (totalItems(), cartItems()). Angular es lo suficientemente inteligente como para saber que solo necesita volver a renderizar el total cuando el computed signal totalPrice le notifica un cambio.

Conclusiones

Los Signals son mucho más que una simple característica nueva. Representan un cambio de paradigma hacia un modelo de reactividad más simple, predecible y extremadamente performante en Angular. Para los desarrolladores que llevan tiempo en el ecosistema, es el momento de desaprender la dependencia implícita de Zone.js y abrazar este nuevo modelo explícito.

Empezar a usar Signals hoy te preparará para el futuro de Angular, te permitirá construir aplicaciones más rápidas y hará que tu manejo del estado sea más robusto y fácil de razonar. No es una moda pasajera, es el futuro de la reactividad en el framework. No te quedes atrás.

Para profundizar aún más, te recomiendo consultar la documentación oficial de Angular sobre Signals.