Listado de la etiqueta: signals

Router Signals en Angular 21: navegación standalone sin cargar todo el Router

Siempre que necesitaba saber si una ruta estaba activa en Angular, inyectaba el Router completo y me suscribía a los eventos. En componentes simples de navegación, era matar moscas a cañonazos. Angular 21 introduce funciones standalone que devuelven Signals — cargás exactamente lo que necesitás, sin más.

El problema con inyectar el Router entero

// ❌ Antes: inyectar todo el Router para saber si una ruta está activa
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

@Component({ ... })
export class SidebarComponent implements OnInit, OnDestroy {
  isDashboard = false;
  private sub = Subscription.EMPTY;

  constructor(private router: Router) {}

  ngOnInit() {
    this.sub = this.router.events
      .pipe(filter(e => e instanceof NavigationEnd))
      .subscribe(() => {
        this.isDashboard = this.router.url === '/dashboard';
      });
  }

  ngOnDestroy() { this.sub.unsubscribe(); }
}

isActive(): la función standalone con Signal

// ✅ Angular 21: función standalone, devuelve Signal
import { Component } from '@angular/core';
import { isActive } from '@angular/router';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-sidebar',
  standalone: true,
  imports: [RouterLink],
  template: `
    <nav>
      <a routerLink="/dashboard" [class.active]="isDashboard()">Dashboard</a>
      <a routerLink="/usuarios" [class.active]="isUsuarios()">Usuarios</a>
      <a routerLink="/reportes" [class.active]="isReportes()">Reportes</a>
    </nav>
  `
})
export class SidebarComponent {
  isDashboard = isActive('/dashboard', { exact: true });
  isUsuarios  = isActive('/usuarios');
  isReportes  = isActive('/reportes');
}

Otras funciones standalone del Router

import { isActive, currentRoute, routeParams, queryParams, routeData } from '@angular/router';

@Component({ ... })
export class DetalleComponent {
  // Parámetros de ruta como Signal — sin ActivatedRoute
  params     = routeParams();
  usuarioId  = computed(() => this.params()['id']);

  // Query params como Signal
  qParams = queryParams();
  pagina  = computed(() => Number(this.qParams()['page'] ?? 1));

  // Data estática de la ruta
  data   = routeData();
  titulo = computed(() => this.data()['titulo'] ?? 'Sin título');
}

Navigation API experimental (v21.0)

// app.config.ts — habilitar Navigation API experimental
import { provideExperimentalNavigationApi } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideExperimentalNavigationApi()
  ]
};

Location strategies: control sobre el trailing slash (v21.2)

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withRouterConfig({
      trailingSlash: 'never'  // 'always' | 'never' | 'preserve'
    }))
  ]
};

Por qué el tree-shaking importa

Con las funciones standalone, el bundler puede eliminar del bundle final todo el código del Router que no se usa. Si solo usás isActive y routeParams, el resto no viaja al usuario. En aplicaciones grandes esto puede reducir el bundle inicial de forma significativa — especialmente relevante en conexiones móviles lentas.


Artículo anterior: Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas | Serie Angular 20 → 21.2 | Próximo: Guía práctica: cómo migré un proyecto de Angular 19/20 a Angular 21.2 sin morir en el intento →

Novedades del template en Angular 21: regex, spread, instanceof y más

Angular 21 trajo varias novedades al template que parecen pequeñas pero cambian el día a día. Regex literals directamente en el HTML, spread operator en arrays y objetos, instanceof en expresiones, exhaustive switch blocks. Menos lógica empujada al componente, más expresividad en el template.

Regex literals en el template (v21)

Antes, si quería filtrar con una expresión regular en el template, tenía que crear una propiedad en el componente. Ahora puedo usar el literal directamente.

// Antes: declarar el regex en el componente
// emailRegex = /@empresa\.com$/;

// Ahora: regex literal directo en el template
@if (userEmail() | match: /@empresa\.com$/) {
  <span class="badge">Email corporativo</span>
}

@if (/^\d{8}-\d$/.test(cuit())) {
  <span class="valid">CUIT válido</span>
}

Spread operator en templates (v21.1)

// Spread de arrays
@let todosLosItems = [...itemsActivos(), ...itemsArchivados()];

@for (item of todosLosItems; track item.id) {
  <li>{{ item.nombre }}</li>
}

// Spread de objetos en property binding
<mi-componente [config]="{ ...configBase(), ...configOverride() }" />

instanceof en expresiones del template (v21.2)

// Antes: casteo forzado en el componente
// get esError(): boolean { return this.resultado() instanceof Error; }

// Ahora: directo en el template
@if (resultado() instanceof Error) {
  <app-error-display [error]="resultado()" />
} @else if (resultado() instanceof Array) {
  <app-lista [items]="resultado()" />
} @else {
  <app-detalle [dato]="resultado()" />
}

Exhaustive switch blocks (v21.2)

type Estado = 'activo' | 'pausado' | 'eliminado';

@switch (usuario.estado()) {
  @case ('activo') {
    <span class="badge green">Activo</span>
  }
  @case ('pausado') {
    <span class="badge yellow">Pausado</span>
  }
  @case ('eliminado') {
    <span class="badge red">Eliminado</span>
  }
  // Sin @default: el compilador verifica que todos los casos estén cubiertos
}

Multiple switch case matching (v21.1)

// Múltiples valores en un @case
@switch (rol()) {
  @case ('admin', 'superadmin') {
    <app-panel-admin />
  }
  @case ('editor', 'revisor') {
    <app-panel-editor />
  }
  @default {
    <app-panel-usuario />
  }
}

@let: variables locales en el template

// Sin @let: el Signal se evalúa 3 veces
<p>Hola, {{ usuario().nombre }}</p>
<p>Email: {{ usuario().email }}</p>
<img [src]="usuario().avatar" />

// Con @let: evaluación única, más eficiente
@let u = usuario();
<p>Hola, {{ u.nombre }}</p>
<p>Email: {{ u.email }}</p>
<img [src]="u.avatar" />

ngTemplateOutlet con injector personalizado (v21.2)

<ng-container
  [ngTemplateOutlet]="miTemplate"
  [ngTemplateOutletContext]="{ $implicit: datos() }"
  [ngTemplateOutletInjector]="injectorPersonalizado">
</ng-container>

Artículo anterior: Signal Forms: cuando los formularios reactivos finalmente tienen sentido | Serie Angular 20 → 21.2 | Próximo: Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas →

Signal Forms: cuando los formularios reactivos finalmente tienen sentido

Los formularios reactivos de Angular siempre me parecieron demasiado verbosos. Crear un FormGroup, definir los FormControl, suscribirse a valueChanges, acordarse de hacer unsubscribe… Signal Forms simplifica todo eso. El formulario es sincrónico, tipado y reactivo sin esfuerzo.

El problema con FormGroup y FormControl

// ❌ FormGroup clásico: verboso y con memory leaks potenciales
import { FormBuilder, Validators } from '@angular/forms';

@Component({ ... })
export class LoginClasico implements OnDestroy {
  private destroy$ = new Subject<void>();

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  constructor(private fb: FormBuilder) {
    this.form.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(val => console.log(val));
  }

  ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}

Signal Forms: la forma moderna

// ✅ Signal Forms: sincrónico, tipado, sin suscripciones
import { Component } from '@angular/core';
import { form, field } from '@angular/forms/signals';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  template: `
    <form (ngSubmit)="onSubmit()">
      <div>
        <label>Email</label>
        <input type="email" [formField]="loginForm.controls.email" />
        @if (loginForm.controls.email.invalid() && loginForm.controls.email.touched()) {
          <span class="error">Email inválido</span>
        }
      </div>
      <div>
        <label>Password</label>
        <input type="password" [formField]="loginForm.controls.password" />
      </div>
      <button type="submit" [disabled]="!loginForm.valid()">Ingresar</button>
    </form>
  `
})
export class LoginComponent {
  loginForm = form({
    email: field('', [Validators.required, Validators.email]),
    password: field('', [Validators.required, Validators.minLength(8)])
  });

  onSubmit() {
    if (this.loginForm.valid()) {
      const { email, password } = this.loginForm.value();
      console.log('Login con:', email);
    }
  }
}

Formularios dinámicos con Signal Forms

import { Component, computed } from '@angular/core';
import { form, field } from '@angular/forms/signals';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-registro',
  template: `
    <form>
      <input type="text" [formField]="registroForm.controls.nombre" placeholder="Nombre" />
      <input type="email" [formField]="registroForm.controls.email" placeholder="Email" />

      <label>
        <input type="checkbox" [formField]="registroForm.controls.esEmpresa" />
        Registro como empresa
      </label>

      @if (registroForm.controls.esEmpresa.value()) {
        <input type="text" [formField]="registroForm.controls.razonSocial" placeholder="Razón Social" />
        <input type="text" [formField]="registroForm.controls.cuit" placeholder="CUIT" />
      }

      <p>Completado: {{ camposCompletados() }}/{{ totalCampos() }}</p>
      <button [disabled]="!registroForm.valid()">Registrarse</button>
    </form>
  `
})
export class RegistroComponent {
  registroForm = form({
    nombre: field('', Validators.required),
    email: field('', [Validators.required, Validators.email]),
    esEmpresa: field(false),
    razonSocial: field(''),
    cuit: field('')
  });

  camposCompletados = computed(() =>
    Object.values(this.registroForm.controls)
      .filter(c => c.value() !== '' && c.value() !== false).length
  );

  totalCampos = computed(() =>
    this.registroForm.controls.esEmpresa.value() ? 5 : 3
  );
}

Migración gradual: FormGroup y Signal Forms coexisten

No hay que reescribir todo de una vez. Angular 21 permite tener ambos sistemas en paralelo. Mi estrategia: nuevos formularios con Signal Forms, los existentes se migran cuando hay un motivo concreto (bug, refactor, mejora de rendimiento).

Lo que más me gusta de Signal Forms

No tener que pensar en el ciclo de vida de las suscripciones. Con FormGroup siempre había ese riesgo latente de un memory leak por un valueChanges sin unsubscribe. Con Signal Forms, el formulario es un valor reactivo sincrónico: lo leo con loginForm.value(), verifico con loginForm.valid(), y Angular maneja el resto. Menos código, menos bugs.


Artículo anterior: Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre | Serie Angular 20 → 21.2 | Próximo: Novedades del template en Angular 21: regex, spread, instanceof y más →

Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre

Cuando leí «Angular sin Zone.js» por primera vez pensé que era un truco de optimización para casos extremos. Ahora es el estándar. Y una vez que lo entendés, no querés volver. La detección de cambios deja de ser magia negra para convertirse en algo predecible y controlable.

Qué hacía Zone.js y por qué era problemático

Zone.js es una librería que parcha todas las APIs asíncronas del browser: setTimeout, setInterval, Promise, addEventListener, XMLHttpRequest. Cuando cualquiera de esas operaciones completa, Zone.js notifica a Angular para que ejecute la detección de cambios en todo el árbol de componentes.

El problema: esa detección recorre todo el árbol aunque solo cambiara un campo en un componente profundo. En aplicaciones grandes, eso genera renders innecesarios que degradan el INP (Interaction to Next Paint), la métrica Core Web Vital que Google usa para el ranking.

// Con Zone.js: cualquier click, timeout o promesa dispara detección en TODO el árbol
// ❌ Angular evalúa todos los componentes aunque nada haya cambiado

// Con Signals: solo los componentes que dependen del Signal actualizado se re-renderizan
// ✅ Detección quirúrgica y predecible

Activar Zoneless en Angular 21

// app.config.ts
import {
  ApplicationConfig,
  provideZonelessChangeDetection
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes)
  ]
};
// angular.json — eliminar zone.js de los polyfills
{
  "projects": {
    "mi-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

// package.json — desinstalar
// npm uninstall zone.js

Signals: el mecanismo que reemplaza a Zone.js

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

@Component({
  selector: 'app-contador',
  template: `
    <p>Contador: {{ contador() }}</p>
    <p>Doble: {{ doble() }}</p>
    <button (click)="incrementar()">+1</button>
  `
})
export class ContadorComponent {
  contador = signal(0);
  doble = computed(() => this.contador() * 2);

  constructor() {
    effect(() => {
      console.log(`Contador cambió a: ${this.contador()}`);
    });
  }

  incrementar() {
    this.contador.update(v => v + 1);
    // Solo re-renderiza este componente — no el árbol completo
  }
}

Signals vs Observables: cuándo uso cada uno

SituaciónSignalObservable (RxJS)
Estado local del componente✅ IdealInnecesariamente complejo
Estado derivado (computed)computed()pipe + map
Streams de eventos complejosNo aplica bien✅ Ideal
HTTP requests✅ httpResourceHttpClient + async pipe
WebSockets / SSECombinado con toSignal()✅ Ideal
Estado global (store)✅ signals + servicesNgRx / Akita

toSignal() y toObservable(): el puente entre mundos

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap } from 'rxjs/operators';

@Component({ ... })
export class MiComponent {
  private http = inject(HttpClient);

  // Observable → Signal (sin async pipe en el template)
  usuarios = toSignal(
    this.http.get<Usuario[]>('/api/usuarios'),
    { initialValue: [] }
  );

  // Signal → Observable (para operadores RxJS)
  busqueda = signal('');
  resultados$ = toObservable(this.busqueda).pipe(
    debounceTime(300),
    switchMap(q => this.http.get(`/api/buscar?q=${q}`))
  );
}

El impacto real en rendimiento

En una aplicación de gestión interna que migramos — dashboard con tablas de datos, filtros y actualizaciones frecuentes — la migración a Zoneless redujo el INP de 380ms a 95ms. La diferencia viene de eliminar los ciclos de detección de cambios innecesarios que Zone.js disparaba con cada interacción del usuario, aunque ningún dato de la vista hubiera cambiado.


Artículo anterior: Angular 21: el cambio de paradigma que no podés ignorar | Serie Angular 20 → 21.2 | Próximo: Signal Forms: cuando los formularios reactivos finalmente tienen sentido →

Angular 21: el cambio de paradigma que no podés ignorar

Cuando Angular anunció que las Signals pasaban de experimental a estables, lo tomé como «otra API más para aprender». Me equivoqué. No era una API nueva: era un cambio de paradigma que afecta cómo se detectan cambios, cómo se construyen formularios, cómo funciona el router y hasta cómo escribimos los tests. Angular 21 consolida todo eso. Esta serie es lo que me hubiera gustado tener cuando arranqué la migración.

El contexto: por qué Angular cambió tan profundamente

Durante años, Angular usó Zone.js para detectar cuándo algo cambiaba en la aplicación y disparar la actualización del DOM. Zone.js intercepta todas las operaciones asíncronas — setTimeout, promesas, eventos — y notifica a Angular para que re-evalúe la vista. Funcionaba. Pero tenía un costo: bundle más grande, detección de cambios innecesaria, y comportamiento difícil de predecir en aplicaciones complejas.

Las Signals cambian el modelo de raíz: en lugar de «revisar todo cuando algo asíncrono ocurre», ahora cada valor sabe exactamente quién depende de él y notifica solo a esos dependientes. Es reactivo de verdad, no reactivo por polling.

Qué cambió de Angular 20 a Angular 21

ÁreaAntes (v20)Angular 21
Detección de cambiosZone.js por defectoZoneless por defecto en proyectos nuevos
FormulariosFormGroup / FormControlSignal Forms: form() y field()
RouterInyección de Router classFunciones standalone con Signals
TestingKarma + JasmineVitest por defecto
TemplateSintaxis estándarRegex literals, spread operator, instanceof
Tooling IAManualSoporte MCP nativo (ng mcp)
HTTPHttpClient observablehttpResource con Signals

httpResource: el nuevo cliente HTTP basado en Signals

Una de las novedades más destacadas de v21.2: httpResource. La respuesta de Angular a «¿cómo hago una llamada HTTP y el resultado sea un Signal?»

import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-usuarios',
  template: `
    @if (usuarios.isLoading()) {
      <p>Cargando...</p>
    } @else if (usuarios.error()) {
      <p>Error: {{ usuarios.error()?.message }}</p>
    } @else {
      <ul>
        @for (u of usuarios.value(); track u.id) {
          <li>{{ u.nombre }}</li>
        }
      </ul>
    }
  `
})
export class UsuariosComponent {
  // Sin subscribe, sin async pipe — el resultado es un Signal
  usuarios = httpResource<Usuario[]>('/api/usuarios');

  // Con parámetros reactivos: se re-fetcha cuando cambia el signal
  filtro = signal('admin');
  usuariosFiltrados = httpResource<Usuario[]>(() =>
    `/api/usuarios?rol=${this.filtro()}`
  );
}

La hoja de ruta de esta serie

  1. Este artículo: el panorama general del salto v20 → v21
  2. Adiós Zone.js: detección de cambios Zoneless en profundidad
  3. Signal Forms: formularios reactivos que finalmente tienen sentido
  4. Novedades del template: regex, spread, instanceof y más
  5. Vitest reemplaza Karma + soporte MCP nativo
  6. Router Signals: navegación standalone sin cargar todo el Router
  7. Guía práctica de migración de Angular 19/20 a Angular 21.2

Si estás trabajando con Angular en proyectos .NET — como yo, donde el backend es ASP.NET y el frontend es Angular — estos cambios son especialmente relevantes. La adopción de Signals reduce considerablemente el gap conceptual entre el modelo reactivo del frontend y los patrones async/await del backend. Empecemos.


Serie Angular 20 → 21.2 | Próximo: Adiós Zone.js: cómo Angular 21 cambió la detección de cambios para siempre →