Tabla de contenidos
Angular Signals y RxJS: Gestión Avanzada del Estado en Angular 19
Desde su introducción, Angular ha evolucionado constantemente en la forma en que manejamos el estado y la reactividad. Con el lanzamiento y la maduración de Angular Signals, especialmente en Angular 19, los desarrolladores tienen una herramienta increíblemente potente para crear aplicaciones más eficientes, mantenibles y reactivas. Sin embargo, la verdadera magia surge al combinar Signals con el poder ya establecido de RxJS. Este artículo profundiza en cómo podemos explotar la sinergia entre estas dos paradigmas para una gestión avanzada del estado, optimizando el rendimiento y la experiencia del desarrollador en nuestras aplicaciones Angular más complejas.
La gestión del estado es el corazón de cualquier aplicación front-end. Históricamente, en Angular, RxJS ha sido la herramienta dominante para manejar flujos de datos asíncronos y reactividad. Si bien es extremadamente poderoso, su curva de aprendizaje puede ser empinada, y para escenarios de reactividad más localizada o granular, a veces resultaba excesivo. Aquí es donde Signals brilla, ofreciendo una solución más simple y de grano fino para la reactividad directa. Pero, ¿qué pasa cuando necesitamos la orquestación compleja de RxJS y la simplicidad reactiva de Signals? La respuesta no es elegir uno sobre el otro, sino integrarlos inteligentemente.
Entendiendo Angular Signals: Más Allá de lo Básico
Antes de sumergirnos en la integración, es crucial tener un entendimiento sólido de los fundamentos de Signals y cómo han evolucionado para convertirse en una pieza central del ecosistema de Angular.
Repaso Rápido de la Reactividad con Signals
En su núcleo, un signal es un valor que le dice a Angular cuándo ha cambiado. Esto permite que el framework actualice solo las partes de la interfaz de usuario que dependen de ese valor, en lugar de ejecutar una detección de cambios más amplia. Esto se conoce como reactividad de grano fino.
Hay tres primitivas principales:
signal(): Para crear un valor reactivo mutable.computed(): Para crear un valor reactivo de solo lectura que depende de otros signals y se recalcula solo cuando sus dependencias cambian.effect(): Para ejecutar lógica de efectos secundarios (como actualizar el DOM directamente, logging, etc.) cuando las dependencias de signals cambian. Los efectos no pueden cambiar otros signals directamente para evitar bucles.
Veamos un ejemplo básico:
import { Component, signal, computed, effect } from '@angular/core';@Component({ selector: 'app-user-profile', standalone: true, template: ` <h2>{{ fullName() }}</h2> <p>Correo: {{ email() }}</p> <button (click)="updateName()">Actualizar Nombre</button> `,})export class UserProfileComponent { firstName = signal('Juan'); lastName = signal('Pérez'); email = signal('[email protected]'); // Un signal computado que depende de firstName y lastName fullName = computed(() => `${this.firstName()} ${this.lastName()}`); constructor() { // Un efecto que se ejecuta cada vez que fullName o email cambian effect(() => { console.log(`El usuario ${this.fullName()} tiene el correo ${this.email()}.`); }); } updateName() { this.firstName.set('Pedro'); this.lastName.set('Gómez'); this.email.set('[email protected]'); // Esto también disparará el efecto }}En este ejemplo, fullName es un computed signal que se recalcula solo cuando firstName o lastName cambian. El effect se dispara cuando fullName o email cambian, demostrando cómo Angular rastrea las dependencias automáticamente.
El Mecanismo de los Effects y su Potencial Controlado
Mientras que computed es para derivar estado, effect es para ejecutar lógica que no cambia el estado de la aplicación, sino que reacciona a él. Es fundamental entender que effect debe usarse con moderación y para propósitos específicos, como:
- Logging o métricas.
- Sincronizar con APIs de terceros (ej. Google Maps, librerías de gráficos).
- Modificar el DOM directamente (con precaución, si fuera estrictamente necesario).
- Persistencia de datos en
localStorage.
Los effects se ejecutan al menos una vez y luego cada vez que sus dependencias de signals cambian. Angular proporciona un mecanismo de limpieza para efectos, especialmente útil cuando se manejan suscripciones o recursos externos.
import { Component, signal, effect, OnDestroy, inject, DestroyRef } from '@angular/core';@Component({ selector: 'app-timer', standalone: true, template: ` <h3>Contador: {{ count() }}</h3> <button (click)="startTimer()">Iniciar</button> <button (click)="stopTimer()">Detener</button> `,})export class TimerComponent implements OnDestroy { count = signal(0); private timerRef: any; // Los efectos se asocian al contexto de inyección (por ejemplo, el componente) // y se destruyen automáticamente cuando el contexto se destruye. private timerEffect = effect(() => { console.log(`El contador actual es: ${this.count()}`); // Ejemplo de cómo un efecto puede registrar una limpieza para recursos internos // effect((onCleanup) => { // const interval = setInterval(() => { console.log(this.count()); }, 1000); // onCleanup(() => clearInterval(interval)); // }); }); startTimer() { if (this.timerRef) { clearInterval(this.timerRef); } this.timerRef = setInterval(() => { this.count.update(value => value + 1); }, 1000); } stopTimer() { if (this.timerRef) { clearInterval(this.timerRef); this.timerRef = null; } } ngOnDestroy(): void { this.stopTimer(); // No es necesario destruir effects explícitamente si se crearon en un contexto de componente }}Es crucial entender el ciclo de vida de los effects. Generalmente, están vinculados al contexto de inyección donde se crean. Si se crean dentro de un componente, se destruirán cuando el componente lo haga, a menos que se les proporcione un DestroyRef explícito al crearlos fuera de un contexto de inyección directo, lo que es una buena práctica para efectos más complejos o en servicios.
La Sinergia Perfecta: Integrando Signals y RxJS
Mientras que Signals es excelente para la reactividad de grano fino y síncrona, RxJS sigue siendo insustituible para la orquestación de flujos de datos asíncronos complejos, gestión de errores, reintentos, debouncing, throttling y otras transformaciones de streams. La buena noticia es que Angular 19 (y versiones anteriores con `@angular/core/rxjs-interop`) nos proporciona utilidades para convertir entre Signals y Observables.
Convirtiendo Signals en Observables con toObservable()
A menudo, tendrás un signal y necesitarás alimentarlo a un flujo de RxJS existente, o aplicar operadores complejos que RxJS ofrece. Aquí es donde toObservable() es indispensable. Transforma un signal en un Observable que emite un nuevo valor cada vez que el signal cambia.
import { Component, signal, inject, DestroyRef } from '@angular/core';import { toObservable } from '@angular/core/rxjs-interop';import { debounceTime, distinctUntilChanged } from 'rxjs/operators';@Component({ selector: 'app-search-input', standalone: true, template: ` <input type="text" [value]="searchQuery()" (input)="onSearchInput($event)" placeholder="Buscar..."> <p>Buscando: {{ debouncedSearchQuery() }}</p> `,})export class SearchInputComponent { searchQuery = signal(''); debouncedSearchQuery = signal(''); private destroyRef = inject(DestroyRef); // Para asegurar la limpieza del toObservable constructor() { toObservable(this.searchQuery, { injector: this.destroyRef }) .pipe( debounceTime(300), distinctUntilChanged() ) .subscribe(value => { this.debouncedSearchQuery.set(value); console.log(`Realizando búsqueda para: ${value}`); // Aquí podrías llamar a un servicio para realizar la búsqueda real }); } onSearchInput(event: Event) { this.searchQuery.set((event.target as HTMLInputElement).value); }}En este ejemplo, el searchQuery signal se convierte en un Observable, permitiendo el uso de operadores como debounceTime y distinctUntilChanged para optimizar la lógica de búsqueda. El resultado se vuelve a almacenar en otro signal, debouncedSearchQuery, para su visualización. El injector en toObservable asegura que la suscripción se desuscriba automáticamente cuando el componente se destruya.
Convirtiendo Observables en Signals con toSignal()
Por otro lado, a menudo recibes datos de servicios que devuelven Observables (por ejemplo, llamadas HTTP) y quieres que el componente maneje esa reactividad con Signals. toSignal() es tu mejor amigo aquí. Convierte un Observable en un signal de solo lectura, actualizándose cada vez que el Observable emite un nuevo valor.
import { Component, signal, inject, DestroyRef } from '@angular/core';import { toSignal } from '@angular/core/rxjs-interop';import { HttpClient, HttpClientModule } from '@angular/common/http';import { switchMap, startWith, catchError } from 'rxjs/operators';import { of } from 'rxjs';import { NgIf } from '@angular/common';interface User { id: number; name: string; email: string;}@Component({ selector: 'app-user-data', standalone: true, imports: [HttpClientModule, NgIf], template: ` <h3>Datos del Usuario</h3> <div *ngIf="user() as user; else loading"> <p>ID: {{ user.id }}</p> <p>Nombre: {{ user.name }}</p> <p>Email: {{ user.email }}</p> </div> <ng-template #loading>Cargando usuario...</ng-template> <div *ngIf="error()" style="color: red;">Error al cargar usuario: {{ error() }}</div> <button (click)="loadUser(1)">Cargar Usuario 1</button> <button (click)="loadUser(2)">Cargar Usuario 2</button> `,})export class UserDataComponent { private http = inject(HttpClient); private destroyRef = inject(DestroyRef); private userId = signal(1); // Un signal para manejar errores error = signal<string | null>(null); // Convierte el Observable de la API en un signal user = toSignal( this.userId.pipe( startWith(this.userId()), // Asegura que se emita un valor inicial switchMap(id => this.http.get<User>(`https://jsonplaceholder.typicode.com/users/${id}`).pipe( catchError(err => { this.error.set(`No se pudo cargar el usuario ${id}.`); return of(null); // Emite null para que el signal se actualice, pero el *ngIf no lo muestre }) ) ) ), { initialValue: null, // Valor inicial mientras carga injector: this.destroyRef, // Asociar con el ciclo de vida del componente } ); loadUser(id: number) { this.error.set(null); // Limpiar error previo this.userId.set(id); }}Este ejemplo muestra cómo toSignal simplifica la gestión de datos asíncronos. Cuando userId cambia, el switchMap dispara una nueva solicitud HTTP, y el user signal se actualiza automáticamente con la respuesta o null en caso de error. El initialValue es crucial para manejar el estado de carga inicial, y injector: this.destroyRef asegura que la suscripción subyacente se limpie correctamente.
Estrategias para Flujos de Datos Asíncronos Complejos
La combinación de toSignal y toObservable abre la puerta a patrones muy potentes. Puedes tener signals que controlan la entrada de Observables (ej. filtros de búsqueda, paginación), procesar esos Observables con todo el poder de RxJS, y luego convertir el resultado final de nuevo en un signal para que tus componentes lo consuman fácilmente.
Considera un escenario donde tienes múltiples filtros (categoría, precio mínimo, precio máximo) y una paginación que controlan una lista de productos. Cada filtro puede ser un signal. Puedes convertir estos signals a Observables, combinarlos usando combineLatest, aplicar lógica de debounce, y luego usar switchMap para llamar a tu API de productos. Finalmente, el resultado de la API se convierte en un signal para tu componente.
import { Component, signal, inject, computed, DestroyRef } from '@angular/core';import { toObservable, toSignal } from '@angular/core/rxjs-interop';import { HttpClient, HttpClientModule } from '@angular/common/http';import { combineLatest, switchMap, debounceTime, startWith, catchError, map } from 'rxjs';import { of } from 'rxjs';import { CommonModule, CurrencyPipe } from '@angular/common';interface Product { id: number; name: string; price: number; category: string;}@Component({ selector: 'app-product-list', standalone: true, imports: [CommonModule, HttpClientModule, CurrencyPipe], template: ` <h3>Lista de Productos</h3> <div> <input type="text" placeholder="Categoría" [value]="categoryFilter()" (input)="categoryFilter.set($event.target.value)"> <input type="number" placeholder="Precio Mín." [value]="minPriceFilter()" (input)="minPriceFilter.set(+$event.target.value)"> <input type="number" placeholder="Precio Máx." [value]="maxPriceFilter()" (input)="maxPriceFilter.set(+$event.target.value)"> </div> <div *ngIf="products() as products; else loading"> <p>Total productos: {{ products.length }}</p> <ul> <li *ngFor="let product of products"> {{ product.name }} ({{ product.category }}) - {{ product.price | currency }} </li> </ul> </div> <ng-template #loading>Cargando productos...</ng-template> <div *ngIf="error()" style="color: red;">Error: {{ error() }}</div> `,})export class ProductListComponent { private http = inject(HttpClient); private destroyRef = inject(DestroyRef); categoryFilter = signal(''); minPriceFilter = signal(0); maxPriceFilter = signal(1000); error = signal<string | null>(null); // Convertir signals de filtro a observables private category$ = toObservable(this.categoryFilter, { injector: this.destroyRef }); private minPrice$ = toObservable(this.minPriceFilter, { injector: this.destroyRef }); private maxPrice$ = toObservable(this.maxPriceFilter, { injector: this.destroyRef }); products = toSignal( combineLatest([this.category$, this.minPrice$, this.maxPrice$]).pipe( debounceTime(300), // distinctUntilChanged puede ser problemático con objetos, aquí lo simplificamos // para fines demostrativos, pero en un caso real se requeriría un comparador más robusto // o que los inputs emitieran solo cuando el valor realmente cambie. distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), switchMap(([category, minPrice, maxPrice]) => { this.error.set(null); // Limpiar error const params = new URLSearchParams(); if (category) params.set('category', category); if (minPrice > 0) params.set('price_gte', minPrice.toString()); if (maxPrice < 1000) params.set('price_lte', maxPrice.toString()); return this.http.get<Product[]>(`https://fakestoreapi.com/products?${params.toString()}`).pipe( catchError(err => { this.error.set('Error al cargar productos.'); return of([]); // Devuelve un array vacío en caso de error }) ); }) ), { initialValue: [], injector: this.destroyRef, } );}Este es un ejemplo robusto de cómo combinar la reactividad de Signals con la capacidad de orquestación de RxJS. Los cambios en cualquier filtro actualizan los products signal de forma optimizada, con debounceTime y distinctUntilChanged evitando llamadas innecesarias a la API.
Patrones de Arquitectura para la Gestión del Estado a Gran Escala
Con las herramientas de interoperabilidad en mano, podemos diseñar arquitecturas de gestión de estado más sofisticadas y adaptadas a las necesidades de cada parte de nuestra aplicación.
El Patrón Servicio-Signal para Estado Local Compartido
Para el estado que necesita ser compartido entre varios componentes en una parte específica de la aplicación (pero no necesariamente globalmente), el patrón Servicio-Signal es una excelente opción. Encapsula el estado y la lógica de negocio dentro de un servicio, exponiendo signals de solo lectura para los componentes.
import { Injectable, signal, computed } from '@angular/core';interface Todo { id: number; text: string; completed: boolean;}@Injectable({ providedIn: 'root' })export class TodoService { private todos = signal<Todo[]>([ { id: 1, text: 'Aprender Angular Signals', completed: false }, { id: 2, text: 'Integrar RxJS', completed: true }, ]); // Exponer el estado como un signal de solo lectura public allTodos = this.todos.asReadonly(); public completedTodos = computed(() => this.todos().filter(todo => todo.completed) ); public pendingTodos = computed(() => this.todos().filter(todo => !todo.completed) ); addTodo(text: string) { this.todos.update(currentTodos => [ ...currentTodos, { id: Date.now(), text, completed: false }, ]); } toggleTodoCompletion(id: number) { this.todos.update(currentTodos => currentTodos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }}import { Component, inject } from '@angular/core';import { TodoService } from './todo.service';import { NgFor, NgIf } from '@angular/common';@Component({ selector: 'app-todo-list', standalone: true, imports: [NgFor, NgIf], template: ` <h3>Mis Tareas</h3> <input #newTodoInput type="text" placeholder="Nueva tarea"> <button (click)="addTodo(newTodoInput.value); newTodoInput.value = ''">Agregar</button> <h4>Pendientes ({{ todoService.pendingTodos().length }})</h4> <ul *ngIf="todoService.pendingTodos().length"> <li *ngFor="let todo of todoService.pendingTodos()"> <input type="checkbox" [checked]="todo.completed" (change)="todoService.toggleTodoCompletion(todo.id)"> {{ todo.text }} </li> </ul> <p *ngIf="!todoService.pendingTodos().length">¡No tienes tareas pendientes!</p> <h4>Completadas ({{ todoService.completedTodos().length }})</h4> <ul *ngIf="todoService.completedTodos().length"> <li *ngFor="let todo of todoService.completedTodos()"> <input type="checkbox" [checked]="todo.completed" (change)="todoService.toggleTodoCompletion(todo.id)"> <del>{{ todo.text }}</del> </li> </ul> <p *ngIf="!todoService.completedTodos().length">Aún no has completado ninguna tarea.</p> `,})export class TodoListComponent { todoService = inject(TodoService); addTodo(text: string) { if (text.trim()) { this.todoService.addTodo(text); } }}Este patrón mantiene la lógica del estado centralizada en el servicio, mientras los componentes consumen el estado a través de signals de solo lectura, haciendo el flujo de datos unidireccional y predecible.
Combinando Signals con NgRx (u otras librerías) para Estado Global
Para aplicaciones muy grandes con un estado global complejo y un historial de acciones (undo/redo, time-travel debugging), soluciones como NgRx siguen siendo muy relevantes. Signals no reemplaza a NgRx; más bien, lo complementa.
Puedes usar NgRx para gestionar tu store global, y luego usar toSignal() para convertir selectores de NgRx en signals consumibles por tus componentes. Esto permite a los componentes disfrutar de la reactividad de grano fino de Signals, mientras el estado centralizado y la trazabilidad de NgRx permanecen intactos.
// src/app/store/auth/auth.selectors.ts (Archivo de selectores NgRx)import { createSelector, createFeatureSelector } from '@ngrx/store';// Asumiendo que AuthState es la interfaz de tu estado de autenticacióninterface AuthState { user: { id: number; name: string; email: string } | null; isLoading: boolean; error: string | null;}// Asumiendo que AppState es la interfaz de tu estado globalinterface AppState { auth: AuthState;}const selectAuthState = createFeatureSelector<AuthState>('auth');export const selectCurrentUser = createSelector( selectAuthState, (state: AuthState) => state.user);export const selectIsLoading = createSelector( selectAuthState, (state: AuthState) => state.isLoading);export const selectAuthError = createSelector( selectAuthState, (state: AuthState) => state.error);// src/app/auth-status/auth-status.component.ts (Componente consumiendo NgRx con toSignal)import { Component, inject, DestroyRef } from '@angular/core';import { Store } from '@ngrx/store'; // Asumiendo que NgRx está configuradoimport { toSignal } from '@angular/core/rxjs-interop';import { selectCurrentUser, selectIsLoading, selectAuthError } from '../store/auth/auth.selectors'; // Importa tus selectoresimport { CommonModule } from '@angular/common'; // Para *ngIf@Component({ selector: 'app-auth-status', standalone: true, imports: [CommonModule], template: ` <div> <h3>Estado de Autenticación</h3> <div *ngIf="isLoading()">Cargando usuario...</div> <div *ngIf="authError()" style="color: red;">Error: {{ authError() }}</div> <div *ngIf="currentUser() as user; else loggedOut"> <p>Bienvenido, {{ user.name }}</p> <p>Email: {{ user.email }}</p> <button (click)="logout()">Cerrar Sesión</button> </div> <ng-template #loggedOut><p>No hay usuario logueado. <button (click)="login()">Iniciar Sesión</button></p></ng-template> </div> `,})export class AuthStatusComponent { private store = inject(Store); private destroyRef = inject(DestroyRef); // Convierte los selectores de NgRx (Observables) en signals currentUser = toSignal(this.store.select(selectCurrentUser), { initialValue: null, injector: this.destroyRef, }); isLoading = toSignal(this.store.select(selectIsLoading), { initialValue: false, injector: this.destroyRef, }); authError = toSignal(this.store.select(selectAuthError), { initialValue: null, injector: this.destroyRef, }); login() { // Aquí despacharías una acción de login de NgRx console.log('Dispatching login action...'); // Ejemplo: this.store.dispatch(AuthActions.login({ username: 'test', password: 'password' })); } logout() { // Aquí despacharías una acción de logout de NgRx console.log('Dispatching logout action...'); // Ejemplo: this.store.dispatch(AuthActions.logout()); }}Esta estrategia permite que los componentes se vuelvan más ligeros y aprovechen la reactividad de Signals, delegando la complejidad del estado global a NgRx. Los componentes no necesitan preocuparse por desuscribirse de los selectores, ya que toSignal se encarga de eso.
Migración Gradual de RxJS a Signals en Proyectos Existentes
Para proyectos grandes que ya dependen fuertemente de RxJS, una migración total a Signals puede ser costosa e innecesaria. La clave es la adopción gradual:
- Identificar candidatos: Empieza con nuevas características o componentes pequeños donde la reactividad de grano fino sea una clara ventaja y el estado sea mayormente local.
- Componentes hoja: Los componentes en la parte inferior del árbol (los que no tienen hijos o pocos) son excelentes candidatos para consumir Signals directamente o usar
toSignalpara observables de entrada. - Servicios de encapsulación: Refactoriza servicios para que manejen su estado interno con Signals y lo expongan a través de
asReadonly(). Esto permite que los consumidores sigan usando Observables o Signals según su preferencia durante la transición. - Utilizar la interoperabilidad:
toObservable()ytoSignal()son tus puentes. Úsalos para integrar el código nuevo basado en Signals con el código existente basado en RxJS sin reescribirlo todo de una vez. Por ejemplo, si tienes un servicio que devuelve Observables, los componentes nuevos pueden usartoSignal, mientras los antiguos siguen usandoasync pipe. - Pruebas: Asegúrate de que tus pruebas unitarias y de integración existentes sigan funcionando. El testing de Signals es generalmente más directo, pero la interoperabilidad podría introducir complejidades que debes manejar.
Esta aproximación permite a los equipos adoptar Signals a su propio ritmo, cosechando beneficios sin paralizar el desarrollo.
Casos de Uso Avanzados y Mejores Prácticas con Signals
Más allá de la gestión básica del estado, Signals ofrece oportunidades para optimizar aspectos críticos de la aplicación.
Optimización del Rendimiento y Zonas de Detección de Cambios
Uno de los mayores beneficios de Signals es su capacidad para permitir una detección de cambios de grano fino, lo que puede llevar a mejoras significativas en el rendimiento. Cuando un signal cambia, Angular sabe exactamente qué componentes (o qué partes de un componente) dependen de ese signal y solo los actualiza, sin necesidad de ejecutar la detección de cambios de todo el árbol de componentes.
En Angular 19, con la creciente tendencia hacia las Standalone Components y la posibilidad de ejecutar aplicaciones fuera de NgZone por defecto (una opción que se irá haciendo más madura), Signals se convierte en el mecanismo principal para desencadenar la detección de cambios en componentes OnPush. Al usar Signals, puedes tener componentes OnPush sin llamar manualmente a ChangeDetectorRef.detectChanges(), ya que los componentes reactivos a signals se marcan como sucios automáticamente.
import { Component, signal, ChangeDetectionStrategy, computed } from '@angular/core';@Component({ selector: 'app-performance-demo', standalone: true, template: ` <h3>Contador (OnPush): {{ count() }}</h3> <p>Es par: {{ isEven() ? 'Sí' : 'No' }}</p> <button (click)="increment()">Incrementar</button> `, changeDetection: ChangeDetectionStrategy.OnPush, // Estrategia de detección de cambios de solo pulsar})export class PerformanceDemoComponent { count = signal(0); isEven = computed(() => this.count() % 2 === 0); increment() { this.count.update(val => val + 1); console.log('Signal actualizado. El componente OnPush se actualizará automáticamente.'); }}Este componente, a pesar de usar OnPush, se actualizará correctamente cada vez que count cambie, sin necesidad de inyectar y llamar a ChangeDetectorRef. Esto simplifica enormemente la gestión del rendimiento y elimina la complejidad de NgZone para estas actualizaciones específicas.
Manejo de Formularios Reactivos con Signals
La integración de Signals con el sistema de formularios reactivos de Angular es otra área prometedora. Puedes crear FormControls y vincular sus valores a signals, o derivar el estado de los formularios en signals computados.
import { Component, signal, computed, effect, inject } from '@angular/core';import { FormBuilder, ReactiveFormsModule, Validators, FormControl } from '@angular/forms';import { CommonModule } from '@angular/common'; // Para *ngIf@Component({ selector: 'app-signal-form', standalone: true, imports: [ReactiveFormsModule, CommonModule], template: ` <form [formGroup]="userForm()" (ngSubmit)="onSubmit()"> <div> <label for="name">Nombre:</label> <input id="name" type="text" formControlName="name"> <div *ngIf="nameControl().invalid && nameControl().touched" style="color: red;"> El nombre es requerido. </div> </div> <div> <label for="email">Email:</label> <input id="email" type="email" formControlName="email"> <div *ngIf="emailControl().invalid && emailControl().touched" style="color: red;"> Email inválido. </div> </div> <button type="submit" [disabled]="userForm().invalid">Enviar</button> </form> <p>Formulario válido: {{ userForm().valid }}</p> <p>Valor actual: {{ formValue() | json }}</p> `,})export class SignalFormComponent { private fb = inject(FormBuilder); // Usamos signal para el FormGroup para que cualquier cambio en la estructura del formulario // (aunque menos común) o si lo reemplazamos, sea reactivo. userForm = signal( this.fb.group({ name: ['', Validators.required], email: ['', [Validators.required, Validators.email]], }) ); // Computed signals para acceder a controles específicos de forma reactiva nameControl = computed<FormControl<string | null>>(() => this.userForm().get('name') as FormControl); emailControl = computed<FormControl<string | null>>(() => this.userForm().get('email') as FormControl); // Un signal computado para el valor completo del formulario // toSignal del .valueChanges del formGroup o control también es una opción formValue = computed(() => this.userForm().value); constructor() { effect(() => { console.log('Estado de validez del formulario:', this.userForm().valid); console.log('Valor del formulario ha cambiado:', this.formValue()); }); } onSubmit() { if (this.userForm().valid) { console.log('Formulario enviado:', this.userForm().value); // Aquí enviarías los datos a un servicio, etc. } else { console.log('Formulario inválido'); this.userForm().markAllAsTouched(); } }}Aunque FormControl ya es reactivo por sí mismo (a través de Observables como valueChanges y statusChanges), el uso de computed signals para derivar estado del formulario (como formValue o la validez de controles específicos) permite una integración más fluida con el resto del ecosistema de Signals en tus componentes y facilita la reactividad de grano fino para la UI. Para escenarios más complejos, puedes usar toSignal(form.valueChanges).
Testing de Componentes y Servicios con Signals
El testing con Signals es, en general, más sencillo que con Observables puros, ya que sus valores son síncronos y directamente accesibles. Puedes interactuar con signals usando sus métodos set(), update(), y acceder a su valor usando (). Para computed y effect, las dependencias se gestionan automáticamente, simplificando las pruebas unitarias.
// todo.service.spec.tsimport { TestBed } from '@angular/core/testing';import { TodoService } from './todo.service';describe('TodoService', () => { let service: TodoService; beforeEach(() => { TestBed.configureTestingModule({ providers: [TodoService], }); service = TestBed.inject(TodoService); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should add a todo', () => { const initialLength = service.allTodos().length; service.addTodo('Test Todo'); expect(service.allTodos().length).toBe(initialLength + 1); expect(service.allTodos()[initialLength].text).toBe('Test Todo'); }); it('should toggle todo completion', () => { // Asegurarse de que el primer todo exista y no esté completado inicialmente para esta prueba service.addTodo('Toggle Test'); const todoToToggleId = service.allTodos()[service.allTodos().length - 1].id; const initialCompletion = service.allTodos()[service.allTodos().length - 1].completed; service.toggleTodoCompletion(todoToToggleId); expect(service.allTodos().find(t => t.id === todoToToggleId)?.completed).toBe(!initialCompletion); }); it('should correctly compute pending and completed todos', () => { // Asumiendo un estado inicial de 2 todos: 1 pendiente, 1 completado expect(service.pendingTodos().length).toBe(1); expect(service.completedTodos().length).toBe(1); service.addTodo('Nueva tarea pendiente'); expect(service.pendingTodos().length).toBe(2); expect(service.completedTodos().length).toBe(1); const firstPendingId = service.pendingTodos()[0].id; service.toggleTodoCompletion(firstPendingId); // Marcar como completado expect(service.pendingTodos().length).toBe(1); expect(service.completedTodos().length).toBe(2); });});Para testing de componentes, puedes simular interacciones y verificar cómo los signals en el componente o los servicios inyectados reaccionan y actualizan la plantilla. La simplicidad de acceder a los valores de los signals (signal()) elimina gran parte de la complejidad de la asincronía que a menudo se encuentra al probar Observables. Con Karma o Jest, puedes simular la detección de cambios para asegurarte de que la vista se actualice correctamente después de un cambio de signal.
Desafíos Comunes y Cómo Superarlos
Aunque Signals simplifica muchas cosas, existen desafíos a tener en cuenta:
- Abuso de
effect: Es fácil caer en la trampa de usareffectpara cualquier lógica reactiva. Recuerda queeffectes para efectos secundarios, no para derivar estado (usacomputed) o para cambios de estado directo (usasignal.set()/update()). Un abuso puede llevar a un código difícil de seguir y mantener y puede afectar el rendimiento. - Bucle de reactividad: Aunque Signals está diseñado para prevenir bucles de forma natural (un
effectno puede cambiar unsignalque lo dispara directamente), si combinaseffectcon lógica externa o RxJS de forma incorrecta, podrías introducir bucles indeseados. Siempre piensa en el flujo de datos: inputs -> signals/computeds -> effects/outputs. - Entender la interconexión con RxJS: La conversión entre Signals y Observables debe hacerse de forma consciente. Pregúntate: ¿necesito la orquestación, los operadores y la gestión de la asincronía compleja que ofrece RxJS o la simplicidad y reactividad de grano fino de Signals? Utiliza
toObservableytoSignalcomo puentes deliberados, no por defecto. - Depuración: En un ecosistema de Signals y RxJS combinados, la depuración puede requerir un entendimiento claro de cuándo se dispara un signal, cuándo emite un observable, y cómo se propagan los cambios. Las herramientas de desarrollo de Angular mejorarán continuamente en este aspecto, pero comprender los principios de cada paradigma es clave.
- Curva de aprendizaje para equipos existentes: Para equipos acostumbrados exclusivamente a RxJS, la introducción de Signals puede requerir una reeducación y una definición clara de las mejores prácticas para su uso conjunto.
Conclusión
La combinación de Angular Signals y RxJS representa el futuro de la gestión avanzada del estado en aplicaciones Angular 19 y más allá. Signals ofrece una reactividad de grano fino y una simplicidad inigualable para el estado síncrono y localizado, mientras que RxJS mantiene su posición como la herramienta definitiva para la orquestación de flujos de datos asíncronos complejos y la transformación de streams.
Al dominar las primitivas de Signals, entender las utilidades de interoperabilidad (toObservable, toSignal) y aplicar patrones de arquitectura inteligentes, los desarrolladores de Angular pueden construir aplicaciones que no solo son robustas y escalables, sino también excepcionalmente eficientes y fáciles de mantener. La clave no está en elegir entre uno u otro, sino en comprender dónde cada herramienta brilla más y cómo hacer que trabajen juntas en perfecta armonía. Empieza a experimentar con estas poderosas sinergias en tus proyectos para descubrir todo su potencial. La gestión avanzada del estado en Angular 19 no es un camino único, sino una combinación estratégica de las mejores herramientas disponibles.