Inicio / Angular / Angular Completo: De Cero a Experto / Signals y Gestión de Estado

Signals y Gestión de Estado

Domina el nuevo sistema de reactividad Signals: signal(), computed(), effect(), stores de estado global y los nuevos input()/output().


Signals — Gestión de Estado Reactivo

Signals es el nuevo sistema de reactividad de Angular (introducido en v16, estable en v17). Proporciona una forma más simple, predecible y eficiente de gestionar el estado comparado con ZoneJS y RxJS para casos de uso locales.


¿Qué es un Signal?

Un Signal es un contenedor de valor reactivo. Cuando su valor cambia, Angular sabe exactamente qué partes del template necesitan actualizarse, sin necesidad de verificar todo el árbol de componentes.

Signal: [valor actual] ← puede leerse ← y modificarse
                ↓
           Notifica automáticamente a quien dependa de él

Signals básicos

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

@Component({
  selector: 'app-carrito',
  standalone: true,
  template: `
    <h2>Carrito de compras</h2>
    <p>Productos: {{ totalProductos() }}</p>
    <p>Total: {{ precioTotal() | currency }}</p>
    <p>IVA (19%): {{ iva() | currency }}</p>
    <p>Total con IVA: {{ totalConIva() | currency }}</p>
    <button (click)="agregarProducto()">+ Agregar producto</button>
    <button (click)="limpiar()">🗑️ Limpiar carrito</button>
  `
})
export class CarritoComponent {
  // Signal mutable — valor inicial
  private items = signal<{ nombre: string; precio: number }[]>([]);

  // Signals computed — derivados de otros signals
  totalProductos = computed(() => this.items().length);
  precioTotal = computed(() => this.items().reduce((sum, item) => sum + item.precio, 0));
  iva = computed(() => this.precioTotal() * 0.19);
  totalConIva = computed(() => this.precioTotal() + this.iva());

  constructor() {
    // Effect — se ejecuta cuando cambia alguna dependencia
    effect(() => {
      const total = this.totalConIva();
      if (total > 0) {
        localStorage.setItem('carrito-total', String(total));
      }
    });
  }

  agregarProducto(): void {
    // update — modifica basado en el valor anterior
    this.items.update(items => [
      ...items,
      { nombre: `Producto ${items.length + 1}`, precio: Math.random() * 100 }
    ]);
  }

  limpiar(): void {
    // set — establece un nuevo valor directamente
    this.items.set([]);
  }
}

Métodos de un Signal

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

const contador = signal(0);

// Leer el valor (siempre con paréntesis)
console.log(contador());   // 0

// set — establecer un nuevo valor
contador.set(10);

// update — calcular nuevo valor basado en el anterior
contador.update(n => n + 1);  // 11

// Para objetos: mutate está disponible en signals de objeto
const usuario = signal({ nombre: 'Ana', edad: 28 });

// Actualizar un campo del objeto
usuario.update(u => ({ ...u, edad: 29 }));

// Señales de solo lectura (para exponer desde servicios)
const contadorPublico = contador.asReadonly();
// contadorPublico.set(5);  // ❌ Error: es de solo lectura
// contadorPublico();       // ✅ Solo se puede leer

Computed Signals

Los Computed Signals son derivados de otros signals. Se recalculan automáticamente cuando sus dependencias cambian:

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

const productos = signal([
  { id: 1, nombre: 'Laptop', precio: 999, cantidad: 2 },
  { id: 2, nombre: 'Mouse', precio: 25, cantidad: 1 }
]);
const descuento = signal(0.10);  // 10%

// Se recalcula cuando cambie 'productos' o 'descuento'
const subtotal = computed(() =>
  productos().reduce((sum, p) => sum + p.precio * p.cantidad, 0)
);

const totalConDescuento = computed(() =>
  subtotal() * (1 - descuento())
);

// Los computed son de solo lectura
// totalConDescuento.set(100);  // ❌ Error
console.log(totalConDescuento());  // 2.002 * 0.9 = 1.801.80

Effects

Los effects ejecutan código como efecto secundario cuando cambian las señales que leen:

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

@Component({ selector: 'app-tema', standalone: true, template: '' })
export class TemaComponent {
  tema = signal<'claro' | 'oscuro'>('claro');

  constructor() {
    // Se ejecuta inmediatamente y cada vez que 'tema' cambia
    effect(() => {
      const temaActual = this.tema();
      document.body.classList.toggle('tema-oscuro', temaActual === 'oscuro');
      localStorage.setItem('tema', temaActual);
    });

    // Effect con cleanup
    effect((onCleanup: EffectCleanupRegisterFn) => {
      const intervalo = setInterval(() => {
        console.log('Tick:', this.tema());
      }, 1000);

      // Se ejecuta antes de la siguiente ejecución del effect
      onCleanup(() => clearInterval(intervalo));
    });
  }

  toggleTema(): void {
    this.tema.update(t => t === 'claro' ? 'oscuro' : 'claro');
  }
}

Importante: No modifiques signals dentro de un effect. Puede causar ciclos infinitos. Los effects son para sincronizar con el mundo externo (DOM, localStorage, logging, etc.).


Signals en Servicios — Gestión de Estado Global

// estado/auth.store.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';

export interface Usuario {
  id: number;
  nombre: string;
  email: string;
  rol: 'admin' | 'editor' | 'viewer';
}

@Injectable({ providedIn: 'root' })
export class AuthStore {
  private http = inject(HttpClient);

  // Estado privado
  private _usuario = signal<Usuario | null>(null);
  private _cargando = signal(false);
  private _error = signal<string | null>(null);

  // Estado público (solo lectura)
  readonly usuario = this._usuario.asReadonly();
  readonly cargando = this._cargando.asReadonly();
  readonly error = this._error.asReadonly();

  // Derivados
  readonly estaLogueado = computed(() => this._usuario() !== null);
  readonly esAdmin = computed(() => this._usuario()?.rol === 'admin');
  readonly nombreUsuario = computed(() => this._usuario()?.nombre ?? 'Invitado');

  // Acciones
  async login(email: string, password: string): Promise<void> {
    this._cargando.set(true);
    this._error.set(null);

    try {
      const usuario = await this.http
        .post<Usuario>('/api/auth/login', { email, password })
        .toPromise();

      this._usuario.set(usuario ?? null);
    } catch (err: any) {
      this._error.set(err.message ?? 'Error al iniciar sesión');
    } finally {
      this._cargando.set(false);
    }
  }

  logout(): void {
    this._usuario.set(null);
    this._error.set(null);
  }
}
// Uso en cualquier componente
@Component({
  selector: 'app-navbar',
  standalone: true,
  template: `
    @if (auth.estaLogueado()) {
      <span>Hola, {{ auth.nombreUsuario() }}</span>
      <button (click)="auth.logout()">Cerrar sesión</button>
      @if (auth.esAdmin()) {
        <a routerLink="/admin">Admin</a>
      }
    } @else {
      <a routerLink="/login">Iniciar sesión</a>
    }
  `
})
export class NavbarComponent {
  auth = inject(AuthStore);  // El estado es compartido — singleton
}

Store de productos con Signals

// estado/productos.store.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { tap } from 'rxjs/operators';

export interface Producto {
  id: number;
  nombre: string;
  precio: number;
  categoria: string;
  stock: number;
}

@Injectable({ providedIn: 'root' })
export class ProductosStore {
  private http = inject(HttpClient);

  // Estado
  private _productos = signal<Producto[]>([]);
  private _filtro = signal('');
  private _categoriaSeleccionada = signal('todas');
  private _cargando = signal(false);

  // Público solo lectura
  readonly cargando = this._cargando.asReadonly();

  // Derivados con múltiples filtros
  readonly productosFiltrados = computed(() => {
    const termino = this._filtro().toLowerCase();
    const categoria = this._categoriaSeleccionada();

    return this._productos().filter(p => {
      const coincideNombre = p.nombre.toLowerCase().includes(termino);
      const coincideCategoria = categoria === 'todas' || p.categoria === categoria;
      return coincideNombre && coincideCategoria;
    });
  });

  readonly categorias = computed(() => {
    const cats = new Set(this._productos().map(p => p.categoria));
    return ['todas', ...cats];
  });

  readonly totalProductos = computed(() => this.productosFiltrados().length);

  readonly hayResultados = computed(() => this.totalProductos() > 0);

  // Acciones
  cargarProductos(): void {
    this._cargando.set(true);

    this.http.get<Producto[]>('/api/productos').pipe(
      tap({
        next: (productos) => {
          this._productos.set(productos);
          this._cargando.set(false);
        },
        error: () => this._cargando.set(false)
      })
    ).subscribe();
  }

  filtrar(termino: string): void {
    this._filtro.set(termino);
  }

  seleccionarCategoria(categoria: string): void {
    this._categoriaSeleccionada.set(categoria);
  }

  actualizarStock(id: number, cantidad: number): void {
    this._productos.update(productos =>
      productos.map(p => p.id === id ? { ...p, stock: p.stock + cantidad } : p)
    );
  }

  eliminar(id: number): void {
    this._productos.update(productos => productos.filter(p => p.id !== id));
  }
}

input() y output() — El nuevo API de componentes

Angular 17.1+ introduce input() y output() como alternativa a @Input() y @Output():

import { Component, input, output, model } from '@angular/core';

@Component({
  selector: 'app-calificacion',
  standalone: true,
  template: `
    @for (estrella of estrellas; track estrella) {
      <span
        (click)="seleccionar(estrella)"
        [class.activa]="estrella <= valorActual()">
        ⭐
      </span>
    }
    <p>Calificación: {{ valorActual() }}/{{ max() }}</p>
  `
})
export class CalificacionComponent {
  // input() — equivale a @Input()
  max = input(5);                    // Opcional con valor por defecto
  etiqueta = input.required<string>(); // Requerido

  // model() — equivale a @Input() + @Output() (two-way binding)
  valorActual = model(0);            // [(valorActual)]="miValor" desde el padre

  // output() — equivale a @Output() EventEmitter
  calificacionCambiada = output<number>();

  estrellas = Array.from({ length: this.max() }, (_, i) => i + 1);

  seleccionar(estrella: number): void {
    this.valorActual.set(estrella);           // model se puede setear
    this.calificacionCambiada.emit(estrella); // output emite
  }
}
<!-- Uso desde el padre -->
<app-calificacion
  [max]="10"
  etiqueta="Califica este curso"
  [(valorActual)]="miCalificacion"
  (calificacionCambiada)="onCalificacion($event)">
</app-calificacion>

Resumen: Cuándo usar Signals vs RxJS

Caso de uso Recomendación
Estado local del componente ✅ Signals
Estado global (auth, carrito) ✅ Signals en servicios
Llamadas HTTP ✅ RxJS + AsyncPipe
Búsqueda en tiempo real ✅ RxJS (debounce, switchMap)
Múltiples fuentes de datos combinadas ✅ RxJS (combineLatest, forkJoin)
Eventos del DOM complejos ✅ RxJS (fromEvent)
Interoperabilidad ✅ toSignal() / toObservable()

En proyectos modernos se usan ambos: Signals para el estado y RxJS para flujos de datos asíncronos complejos.