Cómo usar RxJS como gestor de estado en Angular

Cuando construimos una aplicación en Angular, la comunicación entre componentes es un aspecto fundamental que debemos cuidar. Al inicio solemos resolverla entre componentes padre e hijo mediante eventos @Input y @Output. Sin embargo, esta comunicación se limita a relaciones directas entre padres e hijos. Cuando la aplicación empieza a crecer con enrutamiento y múltiples vistas independientes, necesitamos dar el siguiente paso: usar servicios de Angular con RxJS.
Los servicios con RxJS funcionan muy bien en aplicaciones pequeñas con poco estado. Basta con inyectar un servicio con una propiedad basada en BehaviorSubject y suscribirse para mantener los datos sincronizados. Ahora bien, si tu aplicación maneja muchas entidades, ajustes y configuraciones de usuario que deben reflejarse en varios componentes a la vez, depender únicamente de servicios dispersos puede volverse difícil de mantener.
Debemos tener muy clara la responsabilidad de cada componente y saber cuándo conviene refactorizarlo. Crear un componente nuevo para un alcance específico y prestar atención cuando un componente acumula demasiados servicios inyectados es una señal de alerta clara. Veamos un ejemplo práctico para entenderlo mejor.
Escenario
Vamos a construir una aplicación con cuatro secciones: inicio (home), perfil (profile), pedidos (orders) y pagos (payments).
- Cada sección debe permitir guardar datos en el estado.
- La página de inicio debe mostrar los datos acumulados de cada sección.
- Cada pedido debe descontar saldo de la cuenta.
Usaremos BehaviorSubject de RxJS para mantener el estado y algunos operadores para combinar y simplificar el flujo de datos entre pantallas.
Configuración del proyecto
Primero creamos el proyecto con Angular CLI:
>ng new angular-and-states
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? CSSUna vez finalizada la creación, entramos al directorio del proyecto desde la terminal y generamos las páginas ejecutando el comando ng g c junto con la ruta de cada componente:
PS C:\Users\dany.paredes\Desktop\angular-and-states> ng g c /pages/payments
CREATE src/app/pages/payments/payments.component.html (23 bytes)
CREATE src/app/pages/payments/payments.component.spec.ts (640 bytes)
CREATE src/app/pages/payments/payments.component.ts (283 bytes)
CREATE src/app/pages/payments/payments.component.css (0 bytes)
UPDATE src/app/app.module.ts (774 bytes)
PS C:\Users\dany.paredes\Desktop\angular-and-states> ng g c /pages/orders
CREATE src/app/pages/orders/orders.component.html (21 bytes)
CREATE src/app/pages/orders/orders.component.spec.ts (626 bytes)
CREATE src/app/pages/orders/orders.component.ts (275 bytes)
CREATE src/app/pages/orders/orders.component.css (0 bytes)
UPDATE src/app/app.module.ts (862 bytes)
PS C:\Users\dany.paredes\Desktop\angular-and-states> ng g c /pages/profile
CREATE src/app/pages/profile/profile.component.html (22 bytes)
CREATE src/app/pages/profile/profile.component.spec.ts (633 bytes)
CREATE src/app/pages/profile/profile.component.ts (279 bytes)
CREATE src/app/pages/profile/profile.component.css (0 bytes)
UPDATE src/app/app.module.ts (954 bytes)Para la navegación entre páginas, creamos el componente navigation dentro de la carpeta components:
ng g c /components/navigationActualizamos el archivo navigation.component.html con las directivas routerLink para enlazar cada ruta:
<a [routerLink]="['']">Home</a>
<a [routerLink]="['payments']">Payments</a>
<a [routerLink]="['orders']">Orders</a>
<a [routerLink]="['profile']">Profile</a>Luego eliminamos el contenido por defecto en app.component.html y agregamos la navegación junto con <router-outlet></router-outlet>:
<h1>App</h1>
<app-navigation></app-navigation>
<router-outlet></router-outlet>Definimos las rutas correspondientes en app-routing.module.ts:
const routes: Routes = [
{
component: HomeComponent,
path: ''
},
{
component: OrdersComponent,
path: 'orders'
},
{
component: PaymentsComponent,
path: 'payments'
},
{
component: ProfileComponent,
path: 'profile'
}
];Guardamos los cambios y ejecutamos ng s -o. La aplicación se abrirá en el navegador mostrando el componente de inicio con su barra de navegación:

Con la navegación configurada, el siguiente paso es crear los servicios para gestionar el estado de cada vista.
Agregar estado con servicios
Necesitamos guardar el estado de cada sección. Para lograrlo, crearemos un servicio dedicado para cada una: profile, orders y payments.
Usando Angular CLI, ejecutamos ng g s /services/profile para compartir los datos del perfil entre componentes mediante BehaviorSubject.
Conoce más sobre BehaviorSubject
En el servicio definimos dos propiedades:
nameSubject: una instancia privada deBehaviorSubjectinicializada con el valornull.name$: una propiedad pública que exponenameSubjectcomo un observable para que los componentes puedan suscribirse.
@Injectable({
providedIn: 'root'
})
export class ProfileService {
private nameSubject = new BehaviorSubject<string | null>(null);
public name$ = this.nameSubject.asObservable()
public saveName(name: string) {
const message = `Hi ${name} `
this.nameSubject.next(message);
}
}Repetimos el proceso para PaymentService, que se encarga de gestionar el saldo de la cuenta:
ng g s /services/payments
CREATE src/app/services/payments.service.spec.ts (367 bytes)
CREATE src/app/services/payments.service.ts (137 bytes)Creamos dos propiedades (paymentSubject$ iniciando con saldo en 0 y paymentBalance$), además de dos métodos:
addBalance: guarda el saldo en elBehaviorSubject.updateBalance: descuenta el monto correspondiente del saldo actual.
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class PaymentsService {
private paymentSubject$ = new BehaviorSubject<number | null>(null);
public paymentBalance$ = this.paymentSubject.asObservable()
public updateBalance(balance: number) {
const currentBalance = this.paymentSubject.getValue();
if (currentBalance) {
const totalBalance = currentBalance - balance;
this.paymentSubject$.next(totalBalance);
}
}
public addBalance(balance: number) {
this.paymentSubject$.next(balance);
}
}Por último, creamos OrdersService para registrar todos los pedidos:
ng g s /services/orders
CREATE src/app/services/orders.service.spec.ts (357 bytes)
CREATE src/app/services/orders.service.ts (135 bytes)Este servicio almacena un arreglo con los identificadores de pedidos (orderIds):
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs/internal/BehaviorSubject';
@Injectable({
providedIn: 'root'
})
export class OrdersService {
private orderSubject = new BehaviorSubject<number[]>([]);
public orders$ = this.orderSubject.asObservable()
public addOrder(orderId: number) {
const orders = [...this.orderSubject.value, orderId]
this.orderSubject.next(orders);
}
}Ya tenemos los servicios encargados del estado. Ahora podemos integrarlos directamente en los componentes de cada página.
Uso del estado de los servicios en los componentes
Una vez creados los servicios para guardar la información, el siguiente paso es utilizarlos en cada página. El flujo consiste en inyectar el servicio en el componente, consumir su observable para leer los datos y llamar a sus métodos para actualizarlos.
En ProfileComponent permitimos que el usuario guarde su nombre, lo conserve en el observable y ejecute el método saveName del servicio.
Primero inyectamos ProfileService y agregamos la variable name$ conectada al observable del servicio.
Luego creamos el método save(name: string) para llamar a this.profileService.saveName(name).
El código del componente queda de la siguiente manera:
import { ProfileService } from './../../services/profile.service';
import { Component, inject, OnInit } from '@angular/core';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.css'],
})
export class ProfileComponent {
name$ = this.profileService.name$;
constructor(private profileService: ProfileService) {}
save(name: string) {
this.profileService.saveName(name);
}
}En la plantilla HTML agregamos un input con la variable de plantilla #name para capturar el valor escrito, junto con un botón que ejecuta save(name.value).
Para mostrar el nombre en pantalla, usamos el pipe async suscribiéndonos al observable name$:
<h3>Please type your Name:</h3>
<input #name type="text">
<button (click)="save(name.value)">Save</button>
<span *ngIf="(name$ | async ) as nameSaved">{{nameSaved}}</span>Con esto dejamos listo el estado en la sección de perfil. Guardamos los cambios y ejecutamos ng s -o. Al ingresar el nombre y navegar hacia otras secciones, el dato se mantiene en memoria:

Repetimos el mismo proceso para la sección de pagos:
- Inyectamos el servicio en el componente.
- Agregamos el campo de entrada en la plantilla para que el usuario ingrese el valor.
Componente Payment
import {PaymentsService} from './../../services/payments.service';
import {Component} from '@angular/core';
@Component({
selector: 'app-payments',
templateUrl: './payments.component.html',
styleUrls: ['./payments.component.css']
})
export class PaymentsComponent {
balance$ = this.paymentService.paymentBalance$;
constructor(private paymentService: PaymentsService) {
}
updateBalance(balance: HTMLInputElement) {
this.paymentService.addBalance(balance.valueAsNumber);
}
}Actualizamos la plantilla HTML con el input y el botón, y nos suscribimos al observable balance$ con el pipe async:
<h2>Add balance:</h2>
<input #payment type="number">
<button (click)="updateBalance(payment)">Update</button>
<span *ngIf="(balance$ | async ) as balance">You current Balance is: {{balance$ | currency}}</span>Componente Orders
Hacemos un proceso muy parecido con los pedidos. La única diferencia es que los pedidos se manejan en un arreglo y usamos *ngFor para iterar y mostrar cada elemento:
import { Component, OnInit } from '@angular/core';
import { OrdersService } from 'src/app/services/orders.service';
@Component({
selector: 'app-orders',
templateUrl: './orders.component.html',
styleUrls: ['./orders.component.css'],
})
export class OrdersComponent {
orders$ = this.ordersService.orders$;
constructor(private ordersService: OrdersService) {}
addOrder(order: HTMLInputElement) {
if (order.value) {
this.ordersService.addOrder(order.valueAsNumber);
order.value = '';
}
}
}<h2>Add your order</h2>
<input #order type="number">
<button (click)="addOrder(order)">add order</button>
<div *ngIf="(orders$ | async ) as orders">
Your current active orders are:
<div *ngFor="let order of orders">
{{order}}
</div>
</div>En este punto ya tenemos estado en cada una de nuestras entidades. El siguiente reto es leer los valores de cada servicio para mostrarlos en la página de inicio:

Combinar estados y mantener la sincronización
Tenemos tres datos principales en nuestra aplicación:
- Saldo de pagos.
- Información de perfil.
- Lista de pedidos.
Nuestro primer desafío es que, al registrar un nuevo pedido en OrdersComponent, debemos descontar saldo desde PaymentsService.
En orders.component.ts aplicamos los siguientes pasos:
- Inyectamos
paymentsServiceyordersServiceen el constructor. - Declaramos la variable
currentBalance$para obtener el saldo disponible. - Modificamos
addOrderpara actualizar el saldo descontando 2 unidades al registrar el pedido.
El código queda así:
import {PaymentsService} from '../../services/payments.service';
import {Component} from '@angular/core';
import {OrdersService} from 'src/app/services/orders.service';
import {startWith} from 'rxjs/operators';
@Component({
selector: 'app-orders',
templateUrl: './orders.component.html',
styleUrls: ['./orders.component.css'],
})
export class OrdersComponent {
orders$ = this.ordersService.orders$;
currentBalance$ = this.paymentService.paymentBalance$
constructor(
private ordersService: OrdersService,
private paymentService: PaymentsService
) {
}
addOrder(order: HTMLInputElement) {
this.ordersService.addOrder(order.valueAsNumber);
this.paymentService.updateBalance(2);
order.value = '';
}
}En la plantilla HTML nos suscribimos a currentBalance$. Si no hay saldo suficiente, mostramos la plantilla #noBalance y deshabilitamos el botón cuando el saldo sea menor o igual a cero:
<div *ngIf="currentBalance$ | async as balance; else noBalance">
<h2>You have {{balance | currency }} as balance, add your orders</h2>
<input #order type="number">
<button (click)="addOrder(order)" [disabled]="balance <= 0">add order</button>
</div>
<ng-template #noBalance>
<h2> Insuficient Balance, please add.</h2>
</ng-template>
<div *ngIf="orders$ | async as orders">
You have ({{orders.length}}) orders.
<div *ngFor="let order of orders">
{{order}}
</div>
</div>Guardamos los cambios y la aplicación se recarga. Ahora agregamos un saldo inicial y, al crear un pedido, el saldo se descuenta de forma inmediata:

Combinar todos los estados
El caso de Orders fue sencillo porque solo conectamos dos servicios. Pero, ¿qué hacemos cuando necesitamos mostrar información combinada de todos los servicios a la vez?
RxJS ofrece varios operadores para combinar observables, como merge y concat. En nuestro caso usamos combineLatest.
Note: Recuerda que
combineLatestcomo función estática fue reorganizado en versiones recientes de RxJS en favor del operador combineLatestWith.
Combinamos cada observable en un único objeto usando el operador map para consumirlo en la plantilla:
- Inyectamos
ProfileService,OrdersServiceyPaymentsService. - Usamos
combineLatestpara reunir los datos de cada servicio en un solo objeto reactivo y suscribirnos desde la plantilla.
import { Component } from '@angular/core';
import { combineLatest } from 'rxjs';
import { OrdersService } from 'src/app/services/orders.service';
import { PaymentsService } from 'src/app/services/payments.service';
import { ProfileService } from 'src/app/services/profile.service';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css'],
})
export class HomeComponent {
customerStatus$ = combineLatest([
this.paymentService.paymentBalance$,
this.orderService.orders$,
this.profileService.name$,
]).pipe(
map(([balance, orders, profileName]) => ({ balance, orders, profileName }))
);
constructor(
private profileService: ProfileService,
private orderService: OrdersService,
private paymentService: PaymentsService
) { }
}En la plantilla HTML nos suscribimos al observable mediante el pipe async, obteniendo de forma directa el nombre, el saldo y los pedidos:
<div *ngIf="customerStatus$ | async as customerStatus">
<p>Hey! {{customerStatus.profileName}}, your balance is {{ customerStatus.balance | currency }}
in {{customerStatus.orders.length}}</p>
<div *ngFor="let order of customerStatus.orders">
{{ order }}
</div>
</div>Guardamos los cambios. Cuando cualquiera de los observables emite un nuevo valor, el componente de inicio recibe la información actualizada en tiempo real:

Hasta aquí tenemos tres inyecciones en home.component.ts. Funciona bien, pero genera mucho acoplamiento en el componente. Podemos simplificarlo centralizando la lógica en un solo servicio.
Centralizar el BehaviorSubject
Una solución muy efectiva es crear un AppService que reúna todo el estado necesario para la aplicación. En lugar de inyectar múltiples servicios en cada página, pasamos a tener un único punto de entrada:
- Creamos un nuevo servicio llamado
AppService. - Inyectamos
ProfileService,OrdersServiceyPaymentsService. - Usamos el operador
combineLatestpara unificar los datos y exponerlos mediante la propiedadcustomerInfo$. - Exponemos las propiedades
orders$ybalance$. - Agregamos el método
addOrderpara actualizar los pedidos y el saldo en una sola llamada.
import {Injectable} from '@angular/core';
import {ProfileService} from "./profile.service";
import {OrdersService} from "./orders.service";
import {PaymentsService} from "./payments.service";
import {map} from 'rxjs/internal/operators/map';
import {combineLatest} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class AppService {
public customerInfo$ = combineLatest([
this.paymentService.paymentBalance$,
this.profileService.name$,
]).pipe(
map(([balance, name]) => ({balance, name}))
);
public customer$ = this.profileService.name$;
public orders$ = this.orderService.orders$;
public balance$ = this.paymentService.paymentBalance$;
constructor(
private profileService: ProfileService,
private orderService: OrdersService,
private paymentService: PaymentsService
) {
}
addOrder(order: number) {
this.orderService.addOrder(order);
this.paymentService.updateBalance(order);
}
}En home.component.ts retiramos los servicios individuales, inyectamos únicamente AppService en el constructor y actualizamos la referencia de customerStatus$ para que tome customerInfo$ de AppService.
Guardamos y todo sigue funcionando como esperábamos:
import {Component} from '@angular/core';
import {HomeService} from "./home.service";
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css'],
})
export class HomeComponent {
customerStatus$ = this.appService.customerInfo$;
constructor(private appService: AppService) {
}
}Ahora el componente de inicio tiene menos dependencias y el código se ve más ordenado. Aun así, podemos simplificar todavía más la solución delegando responsabilidades en componentes más pequeños.
Responsabilidad de los componentes
La página de inicio tiene dos secciones que se repiten en otras partes: el mensaje con el nombre y saldo del usuario, y la lista de pedidos.
Empecemos creando OrderListComponent con Angular CLI ejecutando ng g c /components/orderlist:
- Inyectamos
AppServiceen el constructor. - Declaramos el observable
orders$para leer la lista de pedidos del servicio. - En la plantilla HTML nos suscribimos a
orders$con el pipeasynce iteramos con*ngFor.
El código queda de la siguiente manera:
import {Component} from '@angular/core';
import {AppService} from "../../services/app.service";
@Component({
selector: 'app-order-list',
templateUrl: './order-list.component.html',
styleUrls: ['./order-list.component.css']
})
export class OrderListComponent {
orders$ = this.appService.orders$;
constructor(private appService: AppService) {
}
}<div *ngIf="orders$ | async as orders">
You have ({{orders.length}}) orders.
<div *ngFor="let order of orders">
{{order}}
</div>
</div>Aplicamos el mismo criterio para el mensaje del cliente creando un componente con Angular CLI:
- Ejecutamos el comando
ng g c /components/customer-message. - Inyectamos
AppServiceen el constructor. - Declaramos el observable
customerInfo$para leer los datos del cliente y su saldo. - En la plantilla HTML nos suscribimos a
customerInfo$con el pipeasync.
El código queda así:
import {Component} from '@angular/core';
import {AppService} from "../../services/app.service";
@Component({
selector: 'app-customer-message',
templateUrl: './customer-message.component.html',
styleUrls: ['./customer-message.component.css']
})
export class CustomerMessageComponent {
public customerInfo$ = this.appService.customerAndBalance$;
constructor(private appService: AppService) {
}
}<div *ngIf="customerInfo$ | async as customer">
<div *ngIf="customer.name && customer.balance; else updateInfo">
<p>Hey! {{customer.name}}, your balance is {{ customer.balance | currency }}</p>
</div>
</div>
<ng-template #updateInfo>
Please add your name and balance
</ng-template>Al inyectar AppService en OrderListComponent y CustomerMessageComponent, desacoplamos la lógica y facilitamos la refactorización de otros componentes.
Refactorizar los componentes
Con estos dos nuevos componentes podemos limpiar tanto la página de inicio como la de pedidos.
El componente Home pasa a funcionar únicamente como un contenedor para CustomerMessageComponent y OrderListComponent:
- Eliminamos la inyección de
AppServicedel constructor. - En la plantilla usamos directamente las etiquetas
<app-customer-message>y<app-order-list>.
El código final de Home queda muy limpio:
import {Component} from '@angular/core';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css'],
})
export class HomeComponent {
}<app-customer-message></app-customer-message>
<app-order-list></app-order-list>El componente Orders también se simplifica notablemente usando AppService y el componente de listado de pedidos:
- Inyectamos
AppServiceen el constructor. - Declaramos el observable
balance$para leer el saldo desdeAppService. - Actualizamos el método
addOrderpara delegar la llamada enthis.appService.addOrder. - En la plantilla nos suscribimos a
balance$con el pipeasync. - Agregamos los componentes
customer-messageyorder-listpara mostrar la información del usuario y la lista de pedidos.
El código final queda así:
import {Component} from '@angular/core';
import {AppService} from "../../services/app.service";
@Component({
selector: 'app-orders',
templateUrl: './orders.component.html',
styleUrls: ['./orders.component.css'],
})
export class OrdersComponent {
balance$ = this.appService.balance$
constructor(
private appService: AppService
) {
}
addOrder(order: HTMLInputElement) {
this.appService.addOrder(order.valueAsNumber)
order.value = '';
}
}<div *ngIf="balance$ | async as balance; else noBalance">
<app-customer-message></app-customer-message>
<input #order type="number">
<button (click)="addOrder(order)" [disabled]="balance <= 0">add order</button>
</div>
<ng-template #noBalance>
<h2> Insufficient Balance, please add.</h2>
</ng-template>
<app-order-list></app-order-list>Con esta separación logramos que cada componente cumpla un rol bien definido.
Lo que aprendí
Fue un ejemplo pequeño pero muy útil para revisar la arquitectura con calma, lejos de la presión de tiempos de entrega y entregas comerciales:
- Usar observables de RxJS nos permite construir aplicaciones reactivas rápidamente sin introducir complejidad innecesaria.
- Cuando cada entidad tiene su propio servicio para mantener el estado, si un componente necesita toda la información, termina inyectando demasiados servicios.
- Para reducir la cantidad de inyecciones, podemos combinar las fuentes en servicios puente como
AppServiceque conectan múltiples instancias deBehaviorSubject. - Es clave identificar qué componentes pueden trabajar de forma aislada para reducir dependencias y mantenerlos simples.
- Aplicar el patrón de componentes contenedores y presentacionales (smart and dumb components) simplifica enormemente la composición del proyecto.
¿Necesito un gestor de estado?
Si tu aplicación es un MVP o un proyecto pequeño como este, usar BehaviorSubject es más que suficiente. Más adelante revisaremos esta misma aplicación implementando librerías de estado dedicadas como NgRx o NGXS.
¡Hasta la próxima!
Photo by Alexandra Smielova on Unsplash
Artículos Relacionados
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.