Volver al blog
Angular Signals State Management Reactivity
Imagen de portada del artículo: Angular Signals: La Revolución de la Reactividad sin RxJS

Angular Signals: La Revolución de la Reactividad sin RxJS

15 min
German

Angular Signals es el cambio más importante en el modelo de reactividad de Angular desde su creación. Introducido en Angular 16 y estabilizado en Angular 17, Signals representa una forma fundamentalmente diferente de pensar sobre el estado y la reactividad en nuestras aplicaciones.

¿Qué es un Signal?

Un Signal es un envoltorio reactivo alrededor de un valor. Cuando ese valor cambia, cualquier cosa que dependa de él se actualiza automáticamente. Es como una celda de una hoja de cálculo: si cambias una celda, todas las fórmulas que la referencian se recalculan.

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

// Crear un signal con un valor inicial
const count = signal(0);

// Leer el valor (hay que llamarlo como función)
console.log(count()); // 0

// Modificar el valor
count.set(5);
count.update(prev => prev + 1); // 6

¿Por qué Signals y no solo RxJS?

Característica RxJS Observables Signals
Suscripción manual Sí (subscribe/unsubscribe) No (automático)
Valor síncrono No (necesita async) Sí (lectura directa)
Curva aprendizaje Alta Baja
Flujos complejos Excelente Limitado (usar RxJS)
Change Detection Zone.js (dirty checking) Granular (solo lo necesario)

Los tres pilares: signal, computed y effect

1. signal() — Estado mutable

Es el contenedor de estado. Puede almacenar cualquier tipo de dato.

typescript
// Primitivos
const name = signal('German');
const age = signal(28);
const isActive = signal(true);

// Objetos y arrays
const user = signal<User>({ name: 'German', role: 'admin' });
const items = signal<string[]>(['Angular', 'TypeScript']);

// Actualizar un objeto (siempre con nueva referencia)
user.update(current => ({ ...current, role: 'editor' }));

// Añadir a un array
items.update(current => [...current, 'Signals']);

Importante: signal.set() y signal.update() comparan por referencia. Para objetos y arrays, siempre crea una nueva referencia.

2. computed() — Valores derivados

computed crea un Signal de solo lectura que se recalcula automáticamente cuando sus dependencias cambian. Es lazy: solo se recalcula cuando alguien lo lee.

typescript
const firstName = signal('German');
const lastName = signal('Cordellat');

// Se recalcula automáticamente cuando firstName o lastName cambian
const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log(fullName()); // "German Cordellat"
firstName.set('Juan');
console.log(fullName()); // "Juan Cordellat"

Ejemplo real: Carrito de compras

typescript
@Component({...})
export class CartComponent {
  items = signal<CartItem[]>([]);
  
  // Todos estos se recalculan automáticamente
  totalItems = computed(() => 
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );
  
  subtotal = computed(() => 
    this.items().reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );
  
  tax = computed(() => this.subtotal() * 0.21);
  
  total = computed(() => this.subtotal() + this.tax());
  
  isEmpty = computed(() => this.items().length === 0);
  
  addItem(product: Product) {
    this.items.update(items => {
      const existing = items.find(i => i.id === product.id);
      if (existing) {
        return items.map(i => 
          i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...items, { ...product, quantity: 1 }];
    });
  }
  
  removeItem(id: string) {
    this.items.update(items => items.filter(i => i.id !== id));
  }
}

En la plantilla:

html
@if (isEmpty()) {
  <p>Tu carrito está vacío</p>
} @else {
  @for (item of items(); track item.id) {
    <cart-item [item]="item" (remove)="removeItem(item.id)" />
  }
  <div class="summary">
    <p>Artículos: {{ totalItems() }}</p>
    <p>Subtotal: {{ subtotal() | currency }}</p>
    <p>IVA (21%): {{ tax() | currency }}</p>
    <p class="font-bold">Total: {{ total() | currency }}</p>
  </div>
}

3. effect() — Efectos secundarios

Los efectos se ejecutan cuando cualquier signal que lean cambia. Son ideales para sincronizar estado con el exterior (localStorage, console, analytics).

typescript
export class ThemeComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    // Se ejecuta cada vez que theme() cambia
    effect(() => {
      document.documentElement.setAttribute('data-theme', this.theme());
      localStorage.setItem('preferred-theme', this.theme());
    });
  }

  toggle() {
    this.theme.update(t => t === 'light' ? 'dark' : 'light');
  }
}

Regla de oro: No modifiques otros signals dentro de un effect(). Los efectos son para comunicarse con el mundo exterior, no para derivar estado (para eso usa computed).

Model Inputs: Signals bidireccionales

Angular 17.1 introduce model(), que crea un signal bidireccional perfecto para componentes de formulario:

typescript
@Component({
  selector: 'app-rating',
  template: `
    @for (star of stars(); track $index) {
      <button (click)="value.set($index + 1)">
        {{ $index < value() ? '★' : '☆' }}
      </button>
    }
  `
})
export class RatingComponent {
  value = model(0);           // Input + Output bidireccional
  max = input(5);             // Input de solo lectura
  stars = computed(() => Array(this.max()));
}

En el padre:

html
<app-rating [(value)]="rating" [max]="10" />
<p>Tu valoración: {{ rating() }}</p>

Signal Store (NgRx SignalStore)

Para aplicaciones más grandes, NgRx ofrece signalStore, una librería de estado basada 100% en Signals:

typescript
export const TodoStore = signalStore(
  withState<TodoState>({
    todos: [],
    filter: 'all',
    loading: false
  }),
  withComputed(({ todos, filter }) => ({
    filteredTodos: computed(() => {
      switch (filter()) {
        case 'active': return todos().filter(t => !t.completed);
        case 'completed': return todos().filter(t => t.completed);
        default: return todos();
      }
    }),
    completedCount: computed(() => todos().filter(t => t.completed).length)
  })),
  withMethods((store, todoService = inject(TodoService)) => ({
    async loadTodos() {
      patchState(store, { loading: true });
      const todos = await firstValueFrom(todoService.getAll());
      patchState(store, { todos, loading: false });
    },
    toggleTodo(id: string) {
      patchState(store, {
        todos: store.todos().map(t => 
          t.id === id ? { ...t, completed: !t.completed } : t
        )
      });
    }
  }))
);

Conclusión

Signals no reemplaza a RxJS, sino que lo complementa. Usa Signals para estado local y sincronía, RxJS para flujos asíncronos complejos, y el paquete rxjs-interop como puente entre ambos mundos. La combinación de los tres es la receta del Angular moderno.