
Angular 19 con Signals y Effects: Reactividad moderna sin RxJS
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 unBehaviorSubject
, pero más simple y sin necesidad desubscribe
. -
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.