Angular 19 con Signals y Effects: Reactividad moderna sin RxJS

Angular 19 con Signals y Effects: Reactividad moderna sin RxJS

Artículospor Luis

Con la llegada de Angular 16, y su consolidación en Angular 17, 18 y ahora 19, el equipo de Angular ha introducido una nueva forma de trabajar con la reactividad que se aleja de la complejidad de RxJS en muchos casos comunes: los Signals y Effects.

En este post, exploraremos cómo crear un proyecto moderno con Angular 19 usando esta nueva API reactiva, y compararemos la solución con un enfoque clásico basado en BehaviorSubject. También presentaremos herramientas como Volta para controlar versiones de Node.js de forma consistente.

Requisitos previos

Versiones que usaremos:

  • Angular: 19.0.0 (estable en junio 2025)
  • Node.js: 20.x LTS
  • NPM: 10.x
  • Volta: última estable

¿Qué es Volta y por qué usarlo?

Volta es un gestor de herramientas para entornos JavaScript que permite instalar y fijar versiones de herramientas como Node.js, npm, Yarn o pnpm. A diferencia de NVM, es más rápido, automático y confiable, ideal para entornos de desarrollo modernos y consistentes.

Instalación de Volta

En macOS con Homebrew:

brew install volta

O con el script oficial:

curl https://get.volta.sh | bash

Configuración del entorno

Una vez instalado:

volta install node@20
volta install npm
volta pin node@20

Esto asegura que todos los comandos dentro del proyecto usen siempre Node.js 20.x, sin necesidad de .nvmrc o pasos manuales.

Volta detecta automáticamente la configuración guardada en el package.json y la aplica en CLI, IDEs o entornos de CI/CD.

Crear proyecto Angular

Durante la creación del proyecto, Angular puede preguntarte si deseas habilitar SSR (Server-Side Rendering) o SSG (Static Site Generation). Para este tutorial, responde “No” a ambas, ya que estamos construyendo una SPA simple que consume una API pública desde el navegador.

Instalación del Angular CLI

Si aún no tienes Angular CLI instalado globalmente, puedes hacerlo con:

npm install -g @angular/cli@19

Esto te permitirá usar el comando ng para crear y administrar proyectos Angular.

npm create @angular@latest
# o si ya tienes Angular CLI
ng new pokemon-signals-demo --standalone --routing --style=css
cd pokemon-signals-demo

Agregar Tailwind CSS al proyecto

Desde Tailwind CSS v4, la forma recomendada de integración con Angular ha cambiado. Ya no se utiliza @tailwind en archivos SCSS ni se genera postcss.config.js, sino que se usa un archivo .postcssrc.json.

Paso 1: Instalar Tailwind CSS

npm install tailwindcss @tailwindcss/postcss postcss --force

Paso 2: Crear el archivo .postcssrc.json

En la raíz del proyecto, crea un archivo llamado .postcssrc.json con el siguiente contenido:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

Paso 3: Crear y configurar el archivo de estilos global

Asegúrate de usar un archivo styles.css (no SCSS) dentro de src/, y en él escribe:

Luego actualiza el archivo angular.json para asegurarte de que src/styles.css sea la hoja de estilo principal.

Importante: Tailwind v4 no es compatible con preprocesadores CSS como SCSS. Usa solo .css para los estilos globales.

Paso 4: Ejecutar la aplicación

ng serve

Qué son Signals y Effects

signal es una forma de declarar un valor reactivo sin depender de RxJS. Funciona como una señal reactiva que notifica automáticamente a Angular para actualizar la vista o ejecutar efectos cuando cambia su valor.

  • signal: crea un valor observable reactivo.
  • computed: deriva valores de otros signals.
  • effect: ejecuta efectos secundarios (como peticiones HTTP o logs) en respuesta a cambios.

Ejemplo real: Buscador de Pokémon con Angular Signals

Vamos a crear un componente que consulte la API pública de PokeAPI y filtre los primeros 151 Pokémon por nombre.

Crear el componente, servicio, tipos y configurar HttpClient

Primero, generamos un componente standalone:

ng generate component PokemonSearch --standalone

También generamos un servicio:

ng generate service pokemon --flat --path=src/app/pokemon

Y creamos una carpeta para los tipos e interfaces del dominio:

mkdir src/app/pokemon/types

Dentro creamos el archivo de modelo:

// src/app/pokemon/types/pokemon.model.ts
export interface Pokemon {
  name: string;
  url: string;
}

Este servicio manejará la comunicación con la API. La interfaz Pokemon se separa en un archivo tipo para mantener una estructura limpia:

import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Pokemon } from '../types/pokemon.model';

@Injectable({ providedIn: 'root' })
export class PokemonService {
  private readonly apiUrl = 'https://pokeapi.co/api/v2/pokemon?limit=151';

  readonly pokemons = signal<Pokemon[]>([]);

  constructor(private http: HttpClient) {
    this.fetchAll();
  }

  fetchAll() {
    this.http.get<any>(this.apiUrl).subscribe(response => {
      this.pokemons.set(response.results);
    });
  }
}

Configurar main.ts para standalone con HttpClient

Edita main.ts para usar PokemonSearchComponent como raíz:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { PokemonSearchComponent } from './app/pokemon-search/pokemon-search.component';

bootstrapApplication(PokemonSearchComponent, {
  providers: [
    provideHttpClient(),
  ]
});

Ver el resultado en el navegador

Dado que PokemonSearchComponent es un componente standalone y está siendo bootstrapped directamente en main.ts, debes asegurarte de que su selector esté presente en index.html.

Por defecto, el selector del componente es:

selector: 'app-pokemon-search'

Entonces, en tu archivo src/index.html, dentro del <body>, agrega:

<body>
  <app-pokemon-search></app-pokemon-search>
</body>

Esto le dice a Angular dónde montar el componente en el DOM. Si no colocas ese selector, obtendrás un error como:

NG05104: The selector "app-pokemon-search" did not match any elements

También podrías cambiar el selector a 'app-root' si prefieres mantener la convención típica de Angular y usar <app-root> en el HTML.

Dado que PokemonSearchComponent es un componente standalone y está siendo bootstrapped directamente en main.ts, el contenido se mostrará en el index.html por defecto de Angular, en el elemento <app-root> (o en su reemplazo si se cambia el selector).

Asegúrate de que el selector usado sea <app-pokemon-search> o lo que corresponda, y que esté vinculado directamente desde main.ts como se muestra arriba.

Vamos a construir una aplicación que cargue la lista de los primeros 151 Pokémon desde la API pública PokeAPI y permita filtrarlos por nombre con un input reactivo.

import { Component, computed, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PokemonService } from '../pokemon/pokemon.service';

@Component({
  selector: 'app-pokemon-search',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './pokemon-search.component.html',
})
export class PokemonSearchComponent {
  // Signal que almacena el valor actual del input
  searchTerm = signal('');

  // Valor computado que depende del signal searchTerm y del signal del servicio
  filteredPokemons = computed(() => {
    const term = this.searchTerm().toLowerCase();
    return this.pokemonService.pokemons().filter(p =>
      p.name.toLowerCase().includes(term)
    );
  });

  // Inyectamos el servicio donde vive el signal con la data
  constructor(public pokemonService: PokemonService) {
    // Opcional: efecto secundario al cambiar el valor del input
    effect(() => {
      console.log('Término de búsqueda:', this.searchTerm());
    });
  }

  // Función que actualiza el signal searchTerm al escribir en el input
  updateSearch(term: string) {
    this.searchTerm.set(term);
  }
}

¿Qué son signal, computed y effect?

  • signal(initialValue): Crea un valor reactivo que notifica automáticamente cuando cambia. Similar a un BehaviorSubject, pero más simple y sin necesidad de subscribe.

  • computed(() => ...): Deriva un valor a partir de uno o varios signals. Solo se recalcula cuando cambian sus dependencias. Ideal para filtros, cálculos derivados, etc.

  • effect(() => ...): Ejecuta un bloque de código cada vez que cambian los signals usados dentro del efecto. Útil para side-effects como logging, tracking, llamadas a APIs, etc.

En este tutorial solo usamos effect como demostración, pero podrías aprovecharlo para:

  • Guardar búsquedas recientes.

  • Sincronizar con el localStorage.

  • Disparar una petición HTTP cada vez que cambie el filtro (aunque no es necesario en este caso porque ya precargamos los 151 Pokémon).

Template HTML

<div class="flex flex-col items-center justify-center h-screen w-screen">
  <div class="w-4/5 min-h-[500px] bg-gray-100 rounded-lg p-4 overflow-y-auto my-8">
    <input
      type="text"
      class="w-full p-2 rounded-md border border-gray-300 bg-white"
      placeholder="Buscar Pokémon..."
      (input)="updateSearch($any($event).target.value)"
    />
    <div class="grid grid-cols-6 gap-4 mt-4">
      <div *ngFor="let pokemon of filteredPokemons()" 
           class="bg-white p-4 rounded-lg border border-gray-200">
        <p class="text-center capitalize">{{ pokemon.name }}</p>
      </div>
    </div>
  </div>
</div>

Comparativa: Cómo se hacía antes con RxJS

A continuación, vemos cómo implementar el mismo comportamiento usando RxJS y el enfoque clásico basado en observables:

// Importamos decoradores y operadores necesarios
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, combineLatest, map } from 'rxjs';

@Component({
  selector: 'app-pokemon-search-rxjs',
  standalone: true,
  templateUrl: './pokemon-search.component.html',
})
export class PokemonSearchRxjsComponent implements OnInit {
  // URL de la API
  private apiUrl = 'https://pokeapi.co/api/v2/pokemon?limit=151';

  // Observable que representa el término de búsqueda
  private searchTerm$ = new BehaviorSubject<string>('');

  // Observable que obtiene todos los Pokémon desde la API
  private allPokemons$ = this.http.get<any>(this.apiUrl).pipe(
    map(res => res.results)
  );

  // Combina ambos observables y filtra los resultados según el término
  filteredPokemons$ = combineLatest([this.allPokemons$, this.searchTerm$]).pipe(
    map(([pokemons, term]) =>
      pokemons.filter((p: any) =>
        p.name.toLowerCase().includes(term.toLowerCase())
      )
    )
  );

  constructor(private http: HttpClient) {}

  // Método de Angular, aunque no es necesario en este ejemplo
  ngOnInit(): void {}

  // Función para actualizar el término de búsqueda
  updateSearch(term: string) {
    this.searchTerm$.next(term);
  }
}

Comparación línea por línea

Angular Signals RxJS clásico
signal('') new BehaviorSubject('')
computed(() => ...) combineLatest([...]).pipe(map(...))
effect(() => ...) subscribe(...) (si hiciera falta)
.set() .next()
Reactividad declarativa integrada Reactividad con suscripción explícita
Sin necesidad de unsubscribe Debes cuidar las suscripciones manuales

Ventajas de usar Signals

  • No necesitas suscripciones ni unsubscribe.
  • Menos boilerplate.
  • Integración directa con Angular y detección de cambios optimizada.
  • Código más declarativo y legible.

Conclusión

Angular 19 marca una evolución en la forma de trabajar con reactividad. Signals y Effects ofrecen una experiencia más directa, clara y moderna, ideal para la mayoría de los casos donde antes usábamos RxJS por obligación. Aunque RxJS sigue siendo útil en escenarios complejos, Signals resuelven el 80% de los casos comunes con menos código y más claridad.

En futuros posts podríamos combinar Signals con rutas, inputs y servicios para crear apps más complejas, o explorar cómo coexistir con código existente basado en RxJS.

-- ¿Quieres recibir mi newsletter? --

No te pierdas de aprender:

Mi newsletter mensual viene con una dosis de inspiración, recursos para descargar, consejos de desarrollo rápidos y los mismos recursos que aprendo.