Cómo gestionar el estado de Angular en sus componentes
Actualizado el 3 de diciembre de 2020
Gracias @AlexOkrushko y @Nartc1410 por el gran comentario.
Gestionar el estado de tu aplicación Angular siempre ha sido un reto.
En este tutorial, explicaré cómo gestionar el estado de tu componente con @ngrx/component-store
. Podrás hacerlo de una forma más organizada y minimizando los bugs e inconsistencias de la UI.
Tabla de contenido
- Requisitos previos
- ¿Qué vamos a construir?
- ¿Qué es el "estado"?
- No todas las aplicaciones Angular necesitan NgRx o NGSX
- El problema
- La solución: @ngrx/component-store
- Inicio
Requisitos previos
- Conocimientos básicos de Angular
- Conocimientos básicos de RXJS
- Cuenta con angular-cli instalado o Stackblitz
¿Qué vamos a construir?
Una aplicación para gestionar el aparcamiento de coches y tendrá las siguientes partes:
store.service
: Donde gestionaremos todo nuestro estado y toda la lógica de la UIparking-lot.service
: Para comunicarnos con el backend (para la demo)app.component
: Componente padre. Consumimos el estado y añadimos coches al parkingcar-list.component
: Para mostrar la lista de coches aparcados.
Si quieres, puedes saltar al código fuente, sin compromiso 🤓 o directamente al tutorial.
¿Qué es el "estado"?
Es la representación de su UI mediante un objeto, y podríamos cambiarlo de diferentes maneras, por ejemplo:
- Solicitud de redes
- Eventos del usuario
- Cambios en el router
- Entre otros
Ejemplo:
state = {
cars: [],
loading: true,
error: '',
}
- Lista de coches en el aparcamiento:
cars: []
- Para cambiar la UI de nuestra app mientras se realiza una operación que tarda en resolverse, por ejemplo, una petición de red:
loading: true
. - Para mostrar los errores que puedan ocurrir durante la ejecución de la aplicación:
error: ''
Casi todos los componentes tienen un estado. Los manejamos indirectamente usando propiedades y cambiándolas durante su ciclo de vida.
En resumen un estado es:
- Es un objeto que representa la vista de tu componente
- No son los datos que vienen del servidor, de hecho, esto puede ser parte de ellos
- Puede tener tantos niveles como necesites
- Es inmutable. Cuando necesitas actualizar una propiedad, no la cambias directamente sino que creas un nuevo objeto con la propiedad modificada.
No todas las aplicaciones Angular necesitan NgRx o NGSX
La mayoría de las aplicaciones Angular no necesitan un sistema de gestión de estado completo. Es mejor gestionar el estado a nivel de componente antes de implementar una solución más compleja a nivel de app como NgRx o NGSX.
El problema
Si tienes un componente inteligente con varios componentes hijos, probablemente tengas muchas propiedades en tu componente padre que necesitas pasar a los componentes hijos.
Seguro que los componentes hijos emiten eventos que cambiarán las propiedades de su padre.
Mantener todos estos cambios en orden y bajo control puede convertirse en una tarea tediosa porque las propiedades cambian en muchos lugares que pueden ser difíciles de rastrear, especialmente en tareas asíncronas.
La solución: @ngrx/component-store
El mismo equipo de NgRx desarrolló @ngrx/component-store. Un servicio basado en ReplaySubject
puede extenderse a un servicio y ser consumido por un componente.
Permite mantener toda la lógica de negocio fuera del componente (o componentes) y sólo se suscribe al estado y actualiza la UI cuando cambia.
El servicio que creas al extender ComponentStore es único para un componente en particular y sus hijos y debe ser inyectado directamente en la propiedad providers
del componente.
¿Cuándo utilizar un @ngrx/store o un @ngrx/component-store?
En su aplicación, puede utilizar ambas. Ambas bibliotecas se complementan.
- Si el estado necesita persistir cuando cambias la URL, ese estado va en tu **estado global
- Si el estado necesita ser limpiado cuando cambias la URL, ese estado va en tu almacén de componentes.
Más información en Comparación de ComponentStore y Store.
Mi recomendación
Si no tienes ninguna libreria para gestinar el estado en tu app y quieres empezar con una, te recomiendo empezar con @ngrx/component-store
y evaluar si necesitas algo más complicado en el futuro.
De esta forma, puedes empezar a implementar la gestión de estados en partes de tu app y escalar de forma eficiente.
Conceptos de @ngrx/component-store
Sólo tiene tres conceptos muy sencillos que tienes que aprender:
- Selectores: Seleccionas y te suscribes al estado, ya sea todo o parte de él
- Actualizador: Para actualizar el estado. Puede ser por partes o en su totalidad
- Efectos: Es también actualizar el estado pero hacer alguna otra tarea necesaria previamente. Por ejemplo, una petición HTTP a una API
Comenzando
La aplicación tendrá una UI con tres secciones:
- Formulario para añadir el carrito
- Tabla con los carros aparcados
- Mensajes de error
Inicialización de la aplicación
El primer paso es crear una nueva aplicación Angular. Con angular-cli. Abre un terminal, ejecuta el comando
ng new parking-lot-app
Iniciamos la aplicación que hemos creado:
cd parking-lot-app
ng serve
A continuación, apunta tu navegador a [http://localhost:4200/](http://localhost: 4200/), y verás tu aplicación Angular funcionando con toda la información por defecto.
Creando utilidades
Lo primero que vas a crear es la interfaz "Coche ". Ejecuta el comando
ng g interface models/car
Abre el archivo app/models/car.ts
y añade:
export interface Car {
plate: string
brand: string
model: string
color: string
}
Lo anterior es el modelo muy básico del coche.
Entonces creas un servicio que se comunicará con el "backend" (sólo para la demo). Ejecutas el comando
ng g service services/parking-lot
Abre el archivo app/services/parking-lot.service.ts
y añade:
import { Injectable } from '@angular/core'
import { Observable, of, throwError } from 'rxjs'
import { delay } from 'rxjs/operators'
import { Car } from '../models/car'
const data: Car[] = [
{
plate: '2FMDK3',
brand: 'Volvo',
model: '960',
color: 'Violet',
},
{
plate: '1GYS4C',
brand: 'Saab',
model: '9-3',
color: 'Purple',
},
{
plate: '1GKS1E',
brand: 'Ford',
model: 'Ranger',
color: 'Indigo',
},
{
plate: '1G6AS5',
brand: 'Volkswagen',
model: 'Golf',
color: 'Aquamarine',
},
]
const FAKE_DELAY = 600
@Injectable({
providedIn: 'root',
})
export class ParkingLotService {
private cars: Car[] = []
constructor() {}
add(plate: string): Observable<Car> {
try {
const existingCar = this.cars.find((eCar: Car) => eCar.plate === plate)
if (existingCar) {
throw `This car with plate ${plate} is already parked`
}
const car = this.getCarByPlate(plate)
this.cars = [...this.cars, car]
return of(car).pipe(delay(FAKE_DELAY))
} catch (error) {
return throwError(error)
}
}
private getCarByPlate(plate: string): Car {
const car = data.find((item: Car) => item.plate === plate)
if (car) {
return car
}
throw `The car with plate ${plate} is not register`
}
}
Datos: Una lista de los coches registrados en nuestro sistema. Actuará como su base de datos de coches para la demo.
FAKE_DELAY
: Para simular un pequeño retraso en la solicitud de la API utilizando el operador delay
de rxjs
.
Métodos:
add
: que recibe la matrícula del vehículo y si existe la añade a la lista de coches aparcados y si no devuelve un error.
getCarByPlate
: este método privado sólo busca en nuestra "base de datos" (data
) el coche con la matrícula, y si no existe, lanza un error.
**Propiedades
car
: Para llevar la cuenta de los coches aparcados en el "backend".
Definir el estado
Para definir el estado, veamos los requisitos de la aplicación:
- El usuario añadirá coches por matrícula (una petición a una API)
- Debe indicar al usuario los errores:
- La matrícula del vehículo no existe en la API
- El vehículo ya está aparcado
- Debe mostrar indicadores en la interfaz de usuario cuando se produce una solicitud
- Cargar: cambiar el texto del botón mientras ocurre la solicitud
- Desactivar: el botón y el campo de texto mientras ocurre la solicitud
- Mostrar el error cuando se produce
En base a estos requisitos, el estado de su UI sería el siguiente
interface State {
cars: Car[]
loading: boolean
error: string
}
- Una lista de coches aparcados
- Un
booleano
para cuando la aplicación hace una petición - Una
cadena
para los mensajes de error
Instalar @ngrx/component-store
Para añadir @ngrx/component-store
a tu aplicación utiliza npm
:
npm install @ngrx/component-store --save
Creación del servicio de tienda
Crea el archivo app/store.service.ts
y añade el siguiente código:
import { Injectable } from '@angular/core'
import { ComponentStore } from '@ngrx/component-store'
import { Car } from './models/car'
// The state model
interface ParkingState {
cars: Car[] // render the table with cars
error: string // show the error when try to add cars
loading: boolean // used to enable/disable elements in the UI while fetching data
}
@Injectable()
export class StoreService extends ComponentStore<ParkingState> {
constructor() {
super({
cars: [],
error: '',
loading: false,
})
}
}
Este código es la base de su StoreService
:
- Has importado
Injectable
(como cualquier otro servicio) yComponentStore
. - Has creado una interfaz
ParkingState
que define el estado de tu componente - Has creado la clase
StoreService
que extiende deComponentStore
y pasa la interfaz - Has inicializado el estado de la UI a través del constructor, haciendo que el estado esté disponible inmediatamente para los consumidores de
ComponentStore
.
Ahora vas a añadir el resto del código, selectos, actualizadores y efectos. Tu código de servicio sería:
import { Injectable } from '@angular/core'
import { ComponentStore } from '@ngrx/component-store'
import { EMPTY, Observable } from 'rxjs'
import { catchError, concatMap, finalize, tap } from 'rxjs/operators'
import { Car } from './models/car'
import { ParkingLotService } from './services/parking-lot.service'
// The state model
interface ParkingState {
cars: Car[] // render the table with cars
error: string // show the error when try to add cars
loading: boolean // used to enable/disable elements in the UI while fetching data
}
@Injectable()
export class StoreService extends ComponentStore<ParkingState> {
constructor(private parkingLotService: ParkingLotService) {
super({
cars: [],
error: '',
loading: false,
})
}
// SELECTORS
readonly vm$: Observable<ParkingState> = this.select((state) => state)
// UPDATERS
readonly updateError = this.updater((state: ParkingState, error: string) => {
return {
...state,
error,
}
})
readonly setLoading = this.updater((state: ParkingState, loading: boolean) => {
return {
...state,
loading,
}
})
readonly updateCars = this.updater((state: ParkingState, car: Car) => {
return {
...state,
error: '',
cars: [...state.cars, car],
}
})
// EFFECTS
readonly = this.effect((plate$: Observable<string>) => {
return plate$.pipe(
concatMap((plate: string) => {
this.setLoading(true)
return this.parkingLotService.add(plate).pipe(
tap({
next: (car) => this.updateCars(car),
error: (e) => this.updateError(e),
}),
finalize(() => {
this.setLoading(false)
}),
catchError(() => EMPTY)
)
})
)
})
}
Es bastante código, así que os lo explicaré por partes y empezaré por los selectores.
Selectores
Para crear un selector, se utiliza el método select
de la siguiente manera:
readonly vm$: Observable<ParkingState> = this.select(state => state);
El método select
espera una función que reciba el estado completo. Con este estado, podemos devolver a los componentes lo que se necesita; en este caso, devuelve el estado completo.
En esta aplicación, se necesita un selector, pero se puede tener más de uno.
Actualizadores
Para actualizar el estado, necesitarás tres actualizadores:
- Para añadir o eliminar el mensaje de error
- Para actualizar la carga
- Para añadir coches al aparcamiento
Para crear actualizadores, utiliza el método update
proporcionado por la clase ComponentStore
.
El método recibe una función con dos parámetros, el primero es el estado actual, y el segundo es la carga útil que el componente envió para actualizar el estado. Este método sólo tiene que devolver el nuevo estado.
Error y loading
readonly updateError = this.updater((state: ParkingState, error: string) => {
return {
...state,
error
};
});
readonly setLoading = this.updater(
(state: ParkingState, loading: boolean) => {
return {
...state,
loading
};
}
);
El updateError
recibe el mensaje de error y utiliza el operador spread para combinarlo con el estado anterior y devolver el nuevo estado.
El setLoading
funciona igual que el anterior pero con la propiedad loading
.
Añadir coches al parking
Este actualizador recibe un coche y simplemente lo añade al array de coches utilizando el operador spread.
readonly updateCars = this.updater((state: ParkingState, car: Car) => {
return {
...state,
error: '',
cars: [...state.cars, car],
};
});
IMPORTANTE: Cuando se actualiza el estado, no se muta el objeto (cambiando alguna propiedad directamente) sino que se devuelve un nuevo objeto siempre.
Efectos
Para añadir un coche al aparcamiento, hay que crear un efecto
porque hay que hacer una petición a una API con la matrícula del coche, y cuando responde, se actualiza el estado.
Para crear los efectos utilizamos el método effect
que recibe un callback con el valor que le pasamos como Observable. Ten en cuenta que cada nueva llamada del efecto empujaría el valor a ese Observable.
readonly addCarToParkingLot = this.effect((plate$: Observable<string>) => {
return plate$.pipe(
concatMap((plate: string) => {
this.setLoading(true);
return this.parkingLotService.add(plate).pipe(
tap({
next: car => this.updateCars(car),
error: e => this.updateError(e)
}),
finalize(() => {
this.setLoading(false);
}),
catchError(() => EMPTY)
);
})
);
});
En este código, se puede ver que el efecto
:
- Recibe la matrícula del coche como un
Observable
. - Actualizar el estado de
loading
. - Solicitar a la API que añada el coche al aparcamiento mediante el
ParkingLotService
. - Cuando la solicitud tenga éxito, actualiza el estado de nuevo: elimina la carga y añade el carro al estado.
- Si falla: quitar la carga y actualizar el estado con el error que viene del "backend"
Usar concatMap
para que si el effect
es llamado varias veces antes de que termine la llamada, resuelva todas las llamadas. Este operador RxJS esperará hasta que la petición anterior se complete para hacer la siguiente.
El operador tap
para manejar el caso de éxito y error.
Y el operador catchError
para manejar posibles errores dentro de la tubería interna.
Creando el componente <car-list>
Ejecuta el siguiente comando para generar el componente.
ng g component components/car-list
En el archivo components/car-list.component.ts
, añade el siguiente código:
import { Component, Input } from '@angular/core'
import { Car } from '../../models/car'
@Component({
selector: 'app-car-list',
templateUrl: './car-list.component.html',
styleUrls: ['./car-list.component.css'],
providers: [],
})
export class CarListComponent {
@Input() cars: Car[] = []
constructor() {}
}
En el archivo components/car-list.component.html
, añade el siguiente código:
<table *ngIf="cars.length; else noCars">
<tr>
<th>Plate</th>
<th>Brand</th>
<th>Model</th>
<th>Color</th>
</tr>
<ng-template ngFor let-car [ngForOf]="cars" let-i="index">
<tr>
<td>{{car.plate}}</td>
<td>{{car.brand}}</td>
<td>{{car.model}}</td>
<td>{{car.color}}</td>
</tr>
</ng-template>
</table>
<ng-template #noCars>
<p>No cars in the parking lot</p>
</ng-template>
En el components/car-list.component.css
hacemos que la tabla se vea elegante:
table {
width: 100%;
border-collapse: collapse;
}
td,
th {
border: solid 1px lightgray;
padding: 0.5rem;
text-align: left;
width: 25%;
}
th {
border-bottom-width: 3px;
}
p {
text-align: center;
}
Por último, asegúrate de que el componente car-list
está añadido al módulo.
Abre el archivo app/app.module.ts
, mira en el array declarations
, y si no está ahí, puedes añadir la clase CarListComponent
manualmente.
Añadiendo el FormModule
Como vas a tener un pequeño formulario con [(ngModel)]
en el app.component
, debes añadir el FormModule
al app.module
.
Abre el archivo app/app.module.ts
y añade el FormsModule
al array imports
. El código final se ve así:
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { AppComponent } from './app.component'
import { CarListComponent } from './components/car-list/car-list.component'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [AppComponent, CarListComponent],
imports: [BrowserModule, FormsModule],
bootstrap: [AppComponent],
})
export class AppModule {}
Consumir el servicio de la tienda
Has creado el servicio específicamente para la app.component
y sus hijos.
app/app.component.ts
Añadir reemplazar todo el código con:
import { Component } from '@angular/core'
import { StoreService } from './store.service'
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [StoreService],
})
export class AppComponent {
plate = ''
vm$ = this.store.vm$
constructor(private store: StoreService) {}
onSubmit($event: Event) {
$event.preventDefault()
this.store.addCarToParkingLot(this.plate)
}
addPlate($event: Event) {
const target = $event.target as HTMLButtonElement
if (target.nodeName === 'BUTTON') {
this.plate = target.innerHTML
}
}
}
El StoreService
maneja toda la lógica de negocio, lo que resulta en un componente diminuto. Veamos el código parte por parte:
Proveedores
proveedores: [StoreService]
: Se inyecta el servicio a nivel de componente para que esta instancia sólo tenga este componente y sus hijos.
Propiedades
plate
: Para el modelo de formulario, el usuario introducirá la matrícula del coche a añadir al aparcamiento.
vm$
Es el estado observable de nuestro StoreService
y se actualiza cada vez que el estado cambia. Nos suscribiremos a esto en el HTML en el siguiente paso.
Métodos
constructor(private store: StoreService) {}
: Inyectas el StoreService
en el constructor, como un servicio normal.
onSubmit()
: Lo llamas cuando se envía el formulario, y lo único que hace es llamar al método del store addCarToParkingLot
(efecto) con la matrícula del coche introducida por el usuario en el formulario.
El método addPlate()
: Este método no es necesario, pero por motivos de demostración, lo he añadido para introducir algunas matrículas pulsando unos botones.
app/app.component.html
Añadir reemplazar todo el código con:
<header>
<h1>Parking Lot Control</h1>
</header>
<ng-container *ngIf="vm$ | async as vm">
<div class="messages">
<p class="error" *ngIf="vm.error">{{vm.error}}</p>
</div>
<div class="box">
<form (submit)="onSubmit($event)">
<input
type="text"
[(ngModel)]="plate"
[ngModelOptions]="{standalone: true}"
placeholder="Ex: 2FMDK3, 1GYS4C, 1GKS1E,1G6AS5"
[disabled]="vm.loading"
/>
<button type="submit" [disabled]="vm.loading || !plate.length">
<ng-container *ngIf="vm.loading; else NotLoading">
Loading...
</ng-container>
<ng-template #NotLoading>
Add Car
</ng-template>
</button>
</form>
<div class="shortcuts">
<h5>Shortcuts</h5>
<p (click)="addPlate($event)" class="examples">
<button>2FMDK3</button>
<button>1GYS4C</button>
<button>1GKS1E</button>
<button>1G6AS5</button>
</p>
</div>
</div>
<app-car-list [cars]="vm.cars"></app-car-list>
</ng-container>
<ng-container *ngIf="vm$ | async as vm">
: Lo primero es obtener el ViewModel de la propiedad vm$
que creamos en la clase componente, usamos async
pipe para suscribirnos, y hacemos una variable estática vm
que el resto de nuestro HTML podrá usar.
Mensaje de error
El error es una string
, por lo que sólo tenemos que mostrarlo en el HTML y utilizando la interpolación:
<p class="error" *ngIf="vm.error">{{vm.error}}</p>
Formulario
Creamos un formulario para que el usuario introduzca la matrícula del coche que quiere añadir al aparcamiento, y enlazamos el evento onSubmit
.
<form (submit)="onSubmit()">
Es un pequeño formulario con un campo de texto para que el usuario introduzca la matrícula y un botón para ejecutar la acción de añadir.
<input>
: Habilita/deshabilita en función de la propiedad loading
del estado.
<botón>
: Se habilita/deshabilita con la propiedad loading
del estado pero también si la propiedad plate
del componente está vacía (evita que se envíe una string
vacía al servicio de la tienda)
En el método onSubmit
del componente, llamamos al efecto con el número de placa introducido por el usuario, y aquí es donde nuestro servicio ComponentStore lo hace todo.
app/app.component.css
Añadimos algunos estilos para que nuestra aplicación se vea muy bien:
h1 {
margin-bottom: 0;
}
.box {
border: solid 1px lightgrey;
padding: 1rem;
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.box p {
margin: 0;
}
.box form {
display: flex;
}
.box form input {
margin-right: 0.5rem;
}
.box form button {
width: 80px;
}
.messages {
height: 2.4rem;
margin: 1rem 0;
}
.messages p {
border: solid 1px transparent;
margin: 0;
padding: 0.5rem;
}
.messages .error {
background-color: lightyellow;
border: solid 1px red;
color: red;
text-align: center;
}
.examples button {
border: 0;
background: none;
color: blue;
text-decoration: underline;
cursor: pointer;
padding: 0;
margin: 0 0.5rem 0 0;
}
.examples button:last-child {
margin: 0;
}
.shortcuts h5 {
margin: 0;
}
.code {
margin-top: 3rem;
border: solid 1px lightgray;
padding: 1rem;
}
.code h4 {
margin: 0 0 1rem;
}
.code pre {
margin: 0;
}
Y en el archivo de estilo global src/styles.css
:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
margen: 3rem;
}
Eso es todo
Ve a tu navegador: https://localhost:4200 y vea su aplicación funcionando.
Resumen
- Has creado un servicio que se comunica con la API:
ParkingLotService
. - Has creado un servicio que maneja toda la lógica y el estado del componente
StoreService
que extiende aComponentStore
. - Tu UI se suscribe al estado del
StoreService
, y cada vez que cambia, tu UI se actualiza.
Usando este enfoque, terminarás con una única "fuente de verdad" para tu UI, fácil de usar sin tener que cambiar el código en muchos lugares para actualizar o mejorar.
Conclusión
Como has podido ver, es mejor empezar a gestionar el estado a nivel de componente antes de saltar a una arquitectura completa.
Un estado es simplemente un objeto que representa el aspecto de tu interfaz, y utilizando @ngrx/component-store
y sus tres conceptos básicos: select
,update
y effect
, puedes manejarlo de una manera simple, directa y más indolora prueba.